Медленно, но верно, я продолжаю делать серию туториалов о WxPython, где я хочу рассмотреть разработку ферймворка для создания нодового интерфейса с нуля и до чего-то вполне функционального и рабочего. В прошлых частях уже рассказано как добавлять ноды, в этой же части, мы их будем соединять, а на этой картинке показан результат, который мы в этой статье получим:
Еще не идеально, но уже вырисовывается что-то вполне полезное и рабочее.
Прошлые части живут тут:
Часть 1: Учимся рисовать
Часть 2: Обработка событий мыши
Часть 3: Продолжаем добавлять фичи + обработка клавиатуры
Часть 4: Реализуем Drag&Drop
13. Создаем простейшее соединение
В погоне за соединениями нод, мы начнем с ключевого компонента, класса соединения, который в простейшем виде выглядит так:
class Connection(CanvasObject):
def __init__(self, source, destination, **kwargs):
super(Connection, self).__init__(**kwargs)
self.source = source
self.destination = destination
def Render(self, gc):
gc.SetPen(wx.Pen('#000000', 1, wx.SOLID))
gc.DrawLines([self.source.position, self.destination.position])
def RenderHighlighting(self, gc):
return
def ReturnObjectUnderCursor(self, pos):
return None
Все просто и тривиально, у нас есть начальный и конечный объекты и мы просто рисуем линию между позициями этих объектов. Вместо остальных методов пока заглушки.
Теперь нам надо реализовать процесс соединения нод. Интерфейс пользователя будет простым: удерживая Shift, пользователь нажимает на исходную ноду и тянет соединение к конечной. Для реализации мы запомним исходный объект при нажатии на него, добавив в «OnMouseLeftDown» следующий код:
if evt.ShiftDown() and self._objectUnderCursor.connectableSource:
self._connectionStartObject = self._objectUnderCursor
При отпускании же кнопки, мы также проверим, чтобы объект под курсором мог принять входящее соединение и соединим их, если все хорошо. Для этого в начале «OnMouseLeftUp» мы добавим соответствующий код:
if (self._connectionStartObject
and self._objectUnderCursor
and self._connectionStartObject != self._objectUnderCursor
and self._objectUnderCursor.connectableDestination):
self.ConnectNodes(self._connectionStartObject, self._objectUnderCursor)
Метод «ConnectNodes» занимается созданием соединения и его регистрацией в обеих соединяемых нодах:
def ConnectNodes(self, source, destination):
newConnection = Connection(source, destination)
self._connectionStartObject.AddOutcomingConnection(newConnection)
self._objectUnderCursor.AddIncomingConnection(newConnection)
Осталось научить ноды быть соединяемыми. Для этого мы введем соответствующий интерфейс, да не один, а целых 3. «ConnectableObject» будет общим интерфейсом для объекта, который может быть соединен с другим объектом. В данном случае, ему необходимо предоставлять точку соединения и центр ноды (чуть позже, мы это будем использовать).
class ConnectableObject(CanvasObject):
def __init__(self, **kwargs):
super(ConnectableObject, self).__init__(**kwargs)
def GetConnectionPortForTargetPoint(self, targetPoint):
"""
GetConnectionPortForTargetPoint method should return an end
point position for a connection object.
"""
raise NotImplementedError()
def GetCenter(self):
"""
GetCenter method should return a center of this object.
It is used during a connection process as a preview of a future connection.
"""
raise NotImplementedError()
Также мы наследуюем от «ConnectableObject» два класс для объектов подходящих для входящих и исходящих соединений:
class ConnectableDestination(ConnectableObject):
def __init__(self, **kwargs):
super(ConnectableDestination, self).__init__(**kwargs)
self.connectableDestination = True
self._incomingConnections = []
def AddIncomingConnection(self, connection):
self._incomingConnections.append(connection)
def DeleteIncomingConnection(self, connection):
self._incomingConnections.remove(connection)
class ConnectableSource(ConnectableObject):
def __init__(self, **kwargs):
super(ConnectableSource, self).__init__(**kwargs)
self.connectableSource = True
self._outcomingConnections = []
def AddOutcomingConnection(self, connection):
self._outcomingConnections.append(connection)
def DeleteOutcomingConnection(self, connection):
self._outcomingConnections.remove(connection)
def GetOutcomingConnections(self):
return self._outcomingConnections
Оба эти класса весьма похожи и позволяют хранить списки входящих и исходящих соединений соответственно. Плюс они устанавливают соответствующие флаги, чтобы канвас знал о том, что такой-то объект может быть соединен.
Остался последний шаг: немного модифицировать нашу ноду, добавив в ее родители соответствующие базовые классы и модифицировать процесс рендеринга. С рендерингом все интересно, можно хранить ноды в канвасе и там же их рендерить, а можно возложить эту задачу на ноду и заставить ее рендерить исходящие соединения. Это мы и сделаем, добавив в код рендеринг ноды вот такой код:
for connection in self.GetOutcomingConnections():
connection.Render(gc)
Итак, если запустить это дело и немного поиграться, то можно получить что-то вроде этого:
Не сильно красиво, но уже функционально:) Текущая версия кода живет тут.
14. Делаем красивые стрелочки
Линии, соединяющие углы нод — это хорошо для теста, но не очень красиво и эстетично. Ну да не страшно, сейчас мы сделаем красивые и эстетичные стрелочки. Для начала, нам понадобится метод рисования стрелочек, который я быстренько написал, вспомнив школьную геометрию и использую NumPy:
def RenderArrow(self, gc, sourcePoint, destinationPoint):
gc.DrawLines([sourcePoint, destinationPoint])
#Draw arrow
p0 = np.array(sourcePoint)
p1 = np.array(destinationPoint)
dp = p0-p1
l = np.linalg.norm(dp)
dp = dp / l
n = np.array([-dp[1], dp[0]])
neck = p1 + self.arrowLength*dp
lp = neck + n*self.arrowWidth
rp = neck - n*self.arrowWidth
gc.DrawLines([lp, destinationPoint])
gc.DrawLines([rp, destinationPoint])
Мы тут отсчитываем «self.arrowLength» от конца стрелочки к началу и затем двигаемся в обе стороны по нормали на расстояние «self.arrowWidth». Так мы находим точки концов отрезков, соединяющих конец стрелочки с… не знаю как это назвать, с концами острия что ли.
Осталось в методе рендеринга заменить рисование линии на рисование стрелочки и можно будет созерцать такую картину:
Код живе тут.
15. Получаем корректные точки концов соединений
Выглядит уже лучше, но еще не совсем красиво, так как концы стрелочке болтаются непонятно где. Для начала мы модифицируем класс нашего соединения, чтобы сделать все более универсальным и добавим туда методы вычисления начальной и конечной точек соединения:
def SourcePoint(self):
return np.array(self.source.GetConnectionPortForTargetPoint(self.destination.GetCenter()))
def DestinationPoint(self):
return np.array(self.destination.GetConnectionPortForTargetPoint(self.source.GetCenter()))
В данном случае, мы просим каждую ноду указать, откуда стоит начинать соединение, передавая ей центр противоположной ноды как другой конец. Это не идеальный и не самый универсальный способ, но для начала сойдет. Рендеринг соединения теперь выглядит так:
def Render(self, gc):
gc.SetPen(wx.Pen('#000000', 1, wx.SOLID))
self.RenderArrow(gc, self.SourcePoint(), self.DestinationPoint())
Осталось собственно реализовать метод «GetConnectionPortForTargetPoint» у ноды, который будет вычислять точку на границе ноды, откуда следует начинать соединение. Для прямоугольника без учета закругленных углов, можно использовать следующий метод:
def GetConnectionPortForTargetPoint(self, targetPoint):
targetPoint = np.array(targetPoint)
center = np.array(self.GetCenter())
direction = targetPoint - center
if direction[0] > 0:
#Check right border
borderX = self.position[0] + self.boundingBoxDimensions[0]
else:
#Check left border
borderX = self.position[0]
if direction[0] == 0:
t1 = float("inf")
else:
t1 = (borderX - center[0]) / direction[0]
if direction[1] > 0:
#Check bottom border
borderY = self.position[1] + self.boundingBoxDimensions[1]
else:
#Check top border
borderY = self.position[1]
if direction[1] == 0:
t2 = float("inf")
else:
t2 = (borderY - center[1]) / direction[1]
t = min(t1, t2)
boundaryPoint = center + t*direction
return boundaryPoint
Тут мы находим бпижайшее пересечение между лучом, выходящим из центра ноды в точку назначение, и сторонами прямоугольника. Этак точка лежит на границе прямоугольника и, в целом, нам подходит. Так мы можем получить что-нибудь такое:
Или что-то, похожее на картинку в самом начале статьи, которая представляет собой иерархию классов текстовой ноды, которая уже близка к чему-то вполне полезному.
Код живет в тут.
PS: Об опечатках пишите в личку.
Автор: Akson87