Pull to refresh

Пример использования WxPython для создания нодового интерфейса. Часть 2: Обработка событий мыши

Reading time 6 min
Views 7.9K
В небольшом цикле статей будет описано использование WxPython для решения вполне конкретной задачи по разработке пользовательского интерфейса, да еще и то, как сделать это решение универсальным. Туториал этот расчитан на тех, кто уже начал изучать эту библиотеку и хочет увидеть что-то более сложное и целостное, чем простейшие примеры (хотя начнется все с относительно простых вещей).

В прошлой части я рассказал о задаче и начал описывать процесс реализации, а точнее рендеринг объектов. Теперь же пришла пора реализовать взаимодействие с пользователем.

Часть 1: Учимся рисовать
Часть 2: Обработка событий мыши
Часть 3: Продолжаем добавлять фичи + обработка клавиатуры
Часть 4: Реализуем Drag&Drop
Часть 5: Соединяем ноды

Кому интересно, добро пожаловать под кат…


Напомню, что в прошлый раз у нас получилась простая программа, которая рисует на канвасе простенькие ноды (пока что прямоугольники с текстом). Пришла пора сделать ноды передвигаемыми.

4. Подсветка объектов при наведении на них курсора


Но перед тем как реализовать перемещение нод, мы сделаем одну полезную фичу: подсветку объекта при наведении на него курсора. Части этой фичи впоследствии пригодятся при реализации остального функционала. Для реализации нам нужно выполнить 3 действия:
1) Отследить перемещение курсора
2) Найти и сохранить самый верхний объект под курсором
3) Отрендерить выделение этого объекта

Для отслеживания перемещения курсора, нам надо добавить обработчик соответствующего события к классу канваса:
        self.Bind(wx.EVT_MOTION, self.OnMouseMotion)

Теперь при перемещении курсора будет вызываться метод:
    def OnMouseMotion(self, evt):
        pos = self.CalcUnscrolledPosition(evt.GetPosition()).Get()
        self._objectUnderCursor = self.FindObjectUnderPoint(pos)
        self.Render()

Тут происходят три действия: так как нам передаются координаты курсора относительно окна, нужно сперва перевести их в координаты канваса (так как у нас есть скрол), затем же мы должны найти соответствующий объект и обновить изображение, чтобы появилась подсветка объекта на который пользователь переместил курсор. Поиском объектов под курсором занимается метод:
    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

Тут все тривиально и не очень. С одной стороны, мы просто проходим по всем объектам и ищем тот, который лежит под курсором. Причем делаем мы это в обратном порядке, так как хотим получить самый верхний, т.е. последний добавленный, объект. С другой стороны, мы используем метод «ReturnObjectUnderCursor», который возвращает нам объект, хотя вроде как мы знаем какой объект мы проверяем. Сделано это с запасом на будущее, чтобы можно было делать ноды, которые содержат другие объекты в себе (например: соединения с другими нодами или углы для изменения размеры ноды). Пока что этот метод у нашей ноды просто проверяет, находится ли курсор в прямоугольнике:
    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

Итак, мы всегда знаем, какой объект находится под курсором, осталось его как-то выделить при рендеринге, что и будет выполнять вот этот код во время рендеринга:
        if self._objectUnderCursor:
            gc.PushState()
            self._objectUnderCursor.RenderHighlighting(gc)
            gc.PopState()

Осталось добавить в ноду код для рендеринга подсветки:
    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)

Тут мы используем прозрачную кисть, чтобы при рендеринге не затереть то, что было отрендерено ранее (т.е. саму ноду).
В итоге получается вот такая картинка:

Курсор пришлось дорисовать постфактум, поэтому он немного не традиционный:)
Не буду приводить тут весь код, кому интересно, вот этот коммит на GitHub'е содержит его.

5. Небольшой рефакторинг и добавление интерфейсов


И снова мы отложим не на долго реализацию перемещения наших объектов, в этот раз для проведения небольшого рефакторинга. Раз уж фреймворк этот должен быть универсальным, значит и ноды тут могут быть всякие разные, в том числе, неперемещаемые (например соединения между объектами, которые задаются самими объектами или какие-то компоненты нод, да и мало ли чего людям в голову взбредет). Так что нам нужен какой-то универсальный способ описания того, что можно, а что нельзя делать с нодами. Да и вообще, хотелось бы какой-то универсальный интерфейс для нод ввести. Правда сейчас мы пока не будем использовать abc, zope.interface или что-то подобное, а просто сделаем базовый класс для объектов на канвасе:
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()

Как вы можете видеть, у нас есть некоторое количество стандартных действий, которые по умолчанию не поддерживаются. Зато есть 3 метода, которые должны быть у любого объекта на канвасе. Что логично, зачем нам такие объекты на канвасе, которые мы не можем увидеть (Render), а как увидим, так потыкать их курсором (ReturnObjectUnderCursor, RenderHighlighting). И тут мы вспоминаем о том, что мы хотим перемещать наши ноды, т.е. они должны быть перемещаемыми, а для этого есть специальный класс:
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

Тут все просто, этот класс разрешает перемещение а также добавляет такое полезное свойство как позиция, так перемещать что-то с одной позиции на другую без наличия этой самой позиции сложно. Теперь определение нашей ноды стало чуть-чуть сложнее, так как она стала наследницей наших новых классов, хотя, в целом, это все та же старая добрая нода:
from MoveMe.Canvas.Objects.Base.CanvasObject import CanvasObject
from MoveMe.Canvas.Objects.Base.MovableObject import MovableObject

class SimpleBoxNode(MovableObject, CanvasObject):
...........

6. Перемещение нод


Вот мы и дошли до долгожданной реализации перемещения нод. Для этого нам требуется делать 2 основных шага: запомнить какой объект пользователь начал тащить (т.е. какой объект был под курсором в момент нажатия левой кнопки мыши) и обновлять позицию объекта при перемещении курсора, пока пользователь не отпустит кнопку мыши.
Первое действие выполняется в:
    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()

Мы просто запоминаем позицию курсора и текущий объект под курсором как перемещаемый, если он поддеживает перемещение. Разве что тут еще есть проверка наличия объекта под курсором, так как смысла перемещать пустоту нет. Вторая же часть немного интереснее
    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()

Первая проверка гарантирует нам, что если пользователь в какой-то момент водит мышкой с отпущеной левой кнопкой, значит он уже точно ничего не перемещает. Это лучше, чем останавливать перемещение по событию отпускания кнопки, так как курсор может быть за пределами окно и тогда мы не получим это событие. Дальше мы проверяем, что мы действительно что-то тащим и начинаем считать относительное перемещение нашего объекта. На данный момент мы не задумываемся о том, что происходит с клавиатурой (не нажат ли Ctrl или еще что, это будет позже). Еще есть проверка на выход за пределы канваса. С проверкой этой все не совсем просто и понятно. С одной стороны, если размер канваса фиксирован, то все так и должно быть, а с другой стороны, хорошо бы было растягивать канвас по ходу дела (хотя и это не является идеальным решением). В общем, на данный момент, размер канваса будет фиксированным и ноды будут упираться в границы канваса.
Вот и все, теперь мы можем перемещать объекты по канвасу. Код живет в этом коммите на GitHub'е. А выглядит это так:


PS: Об опечатках пишите в личку.
Tags:
Hubs:
+15
Comments 0
Comments Leave a comment

Articles