Source code for pyworkflow.gui.canvas

# **************************************************************************
# *
# * Authors:     J.M. De la Rosa Trevin (jmdelarosa@cnb.csic.es)
# *
# * Unidad de  Bioinformatica of Centro Nacional de Biotecnologia , CSIC
# *
# * This program is free software; you can redistribute it and/or modify
# * it under the terms of the GNU General Public License as published by
# * the Free Software Foundation; either version 3 of the License, or
# * (at your option) any later version.
# *
# * This program is distributed in the hope that it will be useful,
# * but WITHOUT ANY WARRANTY; without even the implied warranty of
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# * GNU General Public License for more details.
# *
# * You should have received a copy of the GNU General Public License
# * along with this program; if not, write to the Free Software
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
# * 02111-1307  USA
# *
# *  All comments concerning this program package may be sent to the
# *  e-mail address 'scipion@cnb.csic.es'
# *
# **************************************************************************
"""
This module extends the functionalities of a normal Tkinter Canvas.
The new Canvas class allows to easily display Texboxes and Edges
that can be interactively dragged and clicked.
"""
import logging
logger = logging.getLogger(__name__)
import math
import tkinter as tk
import operator

from pyworkflow import Config
from pyworkflow.gui import gui, getDefaultFont
from pyworkflow.gui.widgets import Scrollable

DEFAULT_ZOOM = 100

DEFAULT_FONT_SIZE = Config.SCIPION_FONT_SIZE

DEFAULT_CONNECTOR_FILL = "blue"
DEFAULT_CONNECTOR_OUTLINE = "black"


[docs]class Canvas(tk.Canvas, Scrollable): """Canvas to draw some objects. It actually is a Frame, a Canvas and scrollbars""" _images = {} def __init__(self, parent, tooltipCallback=None, tooltipDelay=1500, **kwargs): defaults = {'bg': Config.SCIPION_BG_COLOR} defaults.update(kwargs) Scrollable.__init__(self, parent, tk.Canvas, **defaults) self.lastItem = None # Track last item selected self.lastPos = (0, 0) # Track last clicked position self.eventPos = (0, 0) self.dragging = False # Track first clicked position (for a drag action) self.items = {} # Keep a dictionary with high-level items self.cleanSelected = True self.onClickCallback = None self.onDoubleClickCallback = None self.onRightClickCallback = None self.onControlClickCallback = None self.onAreaSelected = None # Add bindings self.bind("<Button-1>", self.onClick) self.bind("<ButtonRelease-1>", self.onButton1Release) self.bind("<Button-3>", self.onRightClick) self.bind("<Button-2>", self.onRightClick) self.bind("<Double-Button-1>", self.onDoubleClick) self.bind("<B1-Motion>", self.onDrag) # Hide the right-click menu self.bind('<FocusOut>', self._unpostMenu) self.bind("<Key>", self._unpostMenu) self.bind("<Control-1>", self.onControlClick) # self.bind("<MouseWheel>", self.onScroll) # Scroll bindings in Linux self.bind("<Control-Button-4>", self.zoomerP) self.bind("<Control-Button-5>", self.zoomerM) self._tooltipId = None self._tooltipOn = False # True if the tooltip is displayed self._tooltipCallback = tooltipCallback self._tooltipDelay = tooltipDelay self._runsFont = getDefaultFont().copy() self._zoomFactor = DEFAULT_ZOOM self.nodeList = None if tooltipCallback: self.bind('<Motion>', self.onMotion) # self.bind('<Leave>', self.onLeave) self._createTooltip() # This should set self._menu = tk.Menu(self, tearoff=0) def _drawGrid(self): """ For debugging purposes. Do not delete. Draws a grid on the canvas to get an ide about where click is happening""" self.update() _, _, width, height = self.frame.bbox(self) for line in range(0, width, 100): # range(start, stop, step) self.create_line([(line, 0), (line, height)], fill='black', tags='grid_line_w') for line in range(0, height, 100): self.create_line([(0, line), (width, line)], fill='black', tags='grid_line_h') def _createTooltip(self): """ Create a Tooltip window to display tooltips in the canvas. """ tw = tk.Toplevel(self) tw.withdraw() # hidden by default tw.wm_overrideredirect(1) # Remove window decorations tw.bind("<Leave>", self.hideTooltip) self._tooltip = tw def _showTooltip(self, x, y, item): # check that the mouse is still in the position nx = self.winfo_pointerx() ny = self.winfo_pointery() if x == nx and y == ny: self._tooltipOn = True tw = self._tooltip # short notation self._tooltipCallback(tw, item) tw.update_idletasks() tw.wm_geometry("+%d+%d" % (x, y)) tw.deiconify()
[docs] def hideTooltip(self, e=None): if self._tooltipOn: self._tooltipOn = False tw = self._tooltip # short notation tw.withdraw()
[docs] def getRunsFont(self): return self._runsFont
[docs] def getImage(self, img): return gui.getImage(img)
def _unpostMenu(self, e=None): self._menu.unpost()
[docs] def getCoordinates(self, event): """Converts the events coordinates to canvas coordinates""" # Convert screen coordinates to canvas coordinates xc = self.canvasx(event.x) yc = self.canvasy(event.y) return xc, yc
[docs] def selectItem(self, item): if self.lastItem: self.lastItem.setSelected(False) self.lastItem = item item.setSelected(True)
[docs] def multipleItemsSelected(self): """ Returns True if more than one box selected, False otherwise. TODO: add numItemsSelected as attribute to Canvas class and update when selection changes """ selectedItemCounts = 0 for k, v in self.items.items(): if v.getSelected(): selectedItemCounts += 1 if selectedItemCounts > 1: return True return False
def _findItem(self, xc, yc): """ Find if there is any item in the canvas in the mouse event coordinates. Return None if not Found """ items = self.find_overlapping(xc - 1, yc - 1, xc + 1, yc + 1) if self.lastItem is not None and self.lastItem.id in items: return self.lastItem for i in items: if i in self.items: return self.items[i] return None def _handleMouseEvent(self, event, callback): # Store last event coordinates self.eventPos = (event.x, event.y) # Retrieve the coordinates relative to the Canvas xc, yc = self.getCoordinates(event) self.lastItem = self._findItem(xc, yc) self.callbackResults = None if callback: self.callbackResults = callback(self.lastItem, event)
[docs] def onClick(self, event): # On click happens completely before onDrag self.cleanSelected = True self._unpostMenu() self._handleMouseEvent(event, self.onClickCallback) if self.lastItem is None: # Moving the canvas as a whole self.move_start(event) else: # Dragging a single box self.captureLastPosition(event)
[docs] def onControlClick(self, event): self.cleanSelected = False self._unpostMenu() self._handleMouseEvent(event, self.onControlClickCallback)
[docs] def onRightClick(self, e=None): # RightClick callback will not work not, as it need # the event information to know the coordinates self._handleMouseEvent(e, self.onRightClickCallback) unpost = True # If the callback return a list of actions # we will show up a menu with them actions = self.callbackResults if actions: self._menu.delete(0, tk.END) for a in actions: if a is None: self._menu.add_separator() else: img = '' label= a[0] size = len(a) if size > 2: # image for the action img = self.getImage(a[2]) # Shortcuts if size > 3: shortCut = a[3] if shortCut: label= "%s (%s)" % (label, shortCut) def getAction(callback): return lambda: callback(e) self._menu.add_command(label=label, command=getAction(a[1]), image=img, compound=tk.LEFT, font=gui.getDefaultFont()) self._menu.post(e.x_root, e.y_root) unpost = False if unpost: self._menu.unpost()
[docs] def onDoubleClick(self, event): self._handleMouseEvent(event, self.onDoubleClickCallback)
# move
[docs] def move_start(self, event): # If nothing was click on ButtonPress if self.lastItem is None: self.captureLastPosition(event) self.config(cursor='fleur') self.scan_mark(event.x, event.y)
[docs] def onDrag(self, event): try: if self.lastItem: xc, yc = self.getCoordinates(event) dx, dy = xc - self.lastPos[0], yc - self.lastPos[1] # logger.info("Moving item %s, %s." % (dx,dy)) self.lastItem.move(dx, dy) self.lastPos = (xc, yc) else: self.dragging = True self.scan_dragto(event.x, event.y, gain=1) except Exception as ex: # JMRT: We are having a weird exception here. # Presumably because there is concurrency between the onDrag # event and the refresh one. For now, just ignore it. pass
[docs] def captureLastPosition(self, event): """ Captures the last position the mouse were located upon the event """ self.lastPos = self.getCoordinates(event)
[docs] def onButton1Release(self, event): if self.dragging: # Failing in "data" view. # TODO: This is not fully implemented # self.onAreaSelected(self.firstPos[0], self.firstPos[1], event.x, event.y) self.dragging = False self.config(cursor='arrow')
[docs] def onMotion(self, event): self.onLeave(event) # Hide tooltip and cancel schedule xc, yc = self.getCoordinates(event) item = self._findItem(xc, yc) if item is not None: self._tooltipId = self.after(self._tooltipDelay, lambda: self._showTooltip(event.x_root, event.y_root, item))
[docs] def onLeave(self, event): if self._tooltipId: self.after_cancel(self._tooltipId) self.hideTooltip()
[docs] def createTextbox(self, text, x, y, bgColor="#99DAE8", textColor='black'): tb = TextBox(self, text, x, y, bgColor, textColor) self.items[tb.id] = tb return tb
[docs] def createTextCircle(self, text, x, y, bgColor="#99DAE8", textColor='black'): tb = TextCircle(self, text, x, y, bgColor, textColor) self.items[tb.id] = tb return tb
[docs] def createRoundedTextbox(self, text, x, y, bgColor="#99DAE8", textColor='black'): tb = RoundedTextBox(self, text, x, y, bgColor, textColor) self.items[tb.id] = tb return tb
[docs] def addItem(self, item): self.items[item.id] = item
[docs] def createEdge(self, srcItem, dstItem): edge = Edge(self, srcItem, dstItem) # self.items[edge.id] = edge return edge
[docs] def createCable(self, src, srcSocket, dst, dstSocket): return Cable(self, src, srcSocket, dst, dstSocket)
[docs] def clear(self): """ Clear all items from the canvas """ self.delete(tk.ALL) self.items.clear()
[docs] def updateScrollRegion(self): self.update_idletasks() self.config(scrollregion=self.bbox("all"))
# linux zoom def __zoom(self, event, scale): newZoomFactor = round(self._zoomFactor * scale) if self._zoomFactor == newZoomFactor: return self._zoomFactor = newZoomFactor # x, y = self.getCoordinates(event) self.scale("all", 0, 0, scale, scale) self.__scaleFonts() self.configure(scrollregion=self.bbox("all")) def __scaleFonts(self): currentFontSize = self._runsFont['size'] newFontSize = currentFontSize zoomPairs = [(32, 7), (44, 6), (53, 5), (66, 4), (73, 3), (82, 2), (90, 1), (105, 0), (120, -1), (137, -2), (999, -3), ] for factor, sizeDecrement in zoomPairs: if self._zoomFactor <= factor: newFontSize = DEFAULT_FONT_SIZE - sizeDecrement break if currentFontSize != newFontSize: self._runsFont['size'] = newFontSize
[docs] def zoomerP(self, event): self.__zoom(event, 1.111111)
[docs] def zoomerM(self, event): self.__zoom(event, 0.9)
[docs] def moveTo(self, x, y): if x > 1 or y > 1: x0,y0,x1,y1 = self.bbox("all") # x dim x = x / (x0+x1) start, end = self.xview() visisble_length = end - start x = x - (visisble_length/2) # Same with y y = y / (y0+y1) start, end = self.yview() visible_length = end - start y = y - (visible_length / 2) self.xview("moveto", x) self.yview("moveto", y)
[docs] def drawGraph(self, graph, layout=None, drawNode=None, nodeList=None): """ Draw a graph in the canvas. nodes in the graph should have x and y. If layout is not None, it will be used to reorganize the node positions. Provide drawNode if you want to customize how to create the boxes for each graph node. """ # Reset the zoom and font scale = self._zoomFactor / DEFAULT_ZOOM self._zoomFactor = DEFAULT_ZOOM self._runsFont['size'] = DEFAULT_FONT_SIZE self.nodeList = nodeList if drawNode is None: self.drawNode = self._drawNode else: self.drawNode = drawNode self._drawNodes(graph.getRoot(), {}) if layout is not None: layout.draw(graph) # Update node positions self._updatePositions(graph.getRoot(), {}) self.updateScrollRegion() self.__zoom(None, scale)
[docs] def reorganizeGraph(self, graph, layout=None): layout.draw(graph) # Update node positions self._updatePositions(graph.getRoot(), {}, createEdges=False) self.updateScrollRegion()
def _drawNode(self, canvas, node): """ Default implementation to draw nodes as textboxes. """ return TextBox(self, node.getLabel(), 0, 0, bgColor="#99DAE8", textColor='black') def _drawNodes(self, node, visitedDict={}): nodeName = node.getName() if nodeName not in visitedDict: visitedDict[nodeName] = True item = self.drawNode(self, node) node.width, node.height = item.getDimensions() node.item = item item.node = node self.addItem(item) if getattr(node, 'expanded', True): for child in node.getChildren(): if self.nodeList is None: self._drawNodes(child, visitedDict) elif self.nodeList.getNode(child.run.getObjId()).isVisible(): self._drawNodes(child, visitedDict) else: self._setupParentProperties(node, visitedDict) else: self._setupParentProperties(node, visitedDict) def _connectParents(self, item): """ Establishes a connection between the visible parents of node's children with node """ logger.debug("Connecting item %s with parents:" % item) visibleParents = self._visibleParents(item, []) for visibleParent in visibleParents: if visibleParent != item: try: logger.debug("Visible parent: %s" % visibleParent) dest = self.items[item.item.id] source = self.items[visibleParent.item.id] visibleParentNode = self.nodeList.getNode(visibleParent.run.getObjId()) itemNode = self.nodeList.getNode(item.run.getObjId()) if visibleParent not in item.getParents() and visibleParentNode.isExpanded(): self.createEdge(source, dest) if not itemNode.isExpanded(): self.createEdge(source, dest) except: logger.warning("Can't connect node %s to parent %s" % (item, visibleParent)) def _visibleParents(self, node, parentlist): """ Return a list with the visible parents of the node's children """ for child in node.getChildren(): parents = child.getParents() for parent in parents: parentNode = self.nodeList.getNode(parent.run.getObjId()) if parentNode.isVisible() and parent != node and parent not in parentlist: parentlist.append(parent) return parentlist def _setupParentProperties(self, node, visitedDict): """ This methods is used for collapsed nodes, in which the properties (width, height, x and y) is propagated to the hidden childs. """ for child in node.getChildren(): if child.getName() not in visitedDict: child.width = node.width child.height = node.height child.x = node.x child.y = node.y self._setupParentProperties(child, visitedDict) def _updatePositions(self, node, visitedDict=None, createEdges=True): """ Update position of nodes and create the edges. """ nodeName = node.getName() if nodeName not in visitedDict: visitedDict[nodeName] = True item = node.item logger.debug("Updating position for node: %s, item: %s" % (node, item)) item.moveTo(node.x, node.y) if getattr(node, 'expanded', True): for child in node.getChildren(): if self.nodeList is None: self.createEdge(item, child.item) self._updatePositions(child, visitedDict, createEdges) elif self.nodeList.getNode(child.run.getObjId()).isVisible(): if createEdges: self.createEdge(item, child.item) self._updatePositions(child, visitedDict, createEdges) else: if createEdges: self._connectParents(node) self._updatePositions(node, visitedDict, createEdges)
[docs]def findClosestPoints(list1, list2): candidates = [] for c1 in list1: for c2 in list2: candidates.append([c1, c2, math.hypot(c2[0] - c1[0], c2[1] - c1[1])]) closestTuple = min(candidates, key=operator.itemgetter(2)) return closestTuple[0], closestTuple[1]
[docs]def findClosestConnectors(item1, item2): return findUpDownClosestConnectors(item1, item2)
[docs]def findUpDownClosestConnectors(item1, item2): srcConnectors = item1.getUpDownConnectorsCoordinates() dstConnectors = item2.getUpDownConnectorsCoordinates() if srcConnectors and dstConnectors: c1Coords, c2Coords = findClosestPoints(srcConnectors, dstConnectors) return c1Coords, c2Coords return None
[docs]def findStrictClosestConnectors(item1, item2): srcConnectors = item1.getConnectorsCoordinates() dstConnectors = item2.getConnectorsCoordinates() c1Coords, c2Coords = findClosestPoints(srcConnectors, dstConnectors) return c1Coords, c2Coords
[docs]def getConnectors(itemSource, itemDest): srcConnector = itemSource.getOutputConnectorCoordinates() dstConnector = itemDest.getInputConnectorCoordinates() return srcConnector, dstConnector
[docs]class Item(object): socketSeparation = 12 verticalFlow = True TOP = 0 RIGHT = 1 BOTTOM = 2 LEFT = 3 def __init__(self, canvas, x, y): self.activeConnector = None self.canvas = canvas self.x = x self.y = y self.sockets = {} self.listeners = [] self.selectionListeners = [] self._selected = False
[docs] def getCenter(self, x1, y1, x2, y2): xc = (x2 + x1) / 2.0 yc = (y2 + y1) / 2.0 return xc, yc
[docs] def getConnectorsCoordinates(self): x1, y1, x2, y2 = self.getCorners() xc, yc = self.getCenter(x1, y1, x2, y2) return [(xc, y1), (x2, yc), (xc, y2), (x1, yc)]
[docs] def getTopConnectorCoordinates(self): return self._getConnectorCoordinates(self.TOP)
[docs] def getBottomConnectorCoordinates(self): return self._getConnectorCoordinates(self.BOTTOM)
[docs] def getLeftConnectorCoordinates(self): return self._getConnectorCoordinates(self.LEFT)
[docs] def getRightConnectorCoordinates(self): return self._getConnectorCoordinates(self.RIGHT)
def _getConnectorCoordinates(self, side): fourConnectors = self.getConnectorsCoordinates() return fourConnectors[side]
[docs] def getInputConnectorCoordinates(self): if self.verticalFlow: return self.getTopConnectorCoordinates() else: return self.getLeftConnectorCoordinates()
[docs] def getOutputConnectorCoordinates(self): if self.verticalFlow: return self.getBottomConnectorCoordinates() else: return self.getRightConnectorCoordinates()
[docs] def getUpDownConnectorsCoordinates(self): corners = self.getCorners() if corners: x1, y1, x2, y2 = self.getCorners() xc, yc = self.getCenter(x1, y1, x2, y2) return [(xc, y1), (xc, y2)] return None
[docs] def getCorners(self): return self.canvas.bbox(self.id)
[docs] def countSockets(self, verticalLocation): return len(list(self.getSocketsAt(verticalLocation)))
[docs] def addSocket(self, name, socketClass, verticalLocation, fillColor=DEFAULT_CONNECTOR_FILL, outline=DEFAULT_CONNECTOR_OUTLINE, position=None): count = self.countSockets(verticalLocation) + 1 if position is None: position = count self.relocateSockets(verticalLocation, count) x, y = self.getSocketCoordsAt(verticalLocation, count, count) self.sockets[name] = {"object": socketClass(self.canvas, x, y, name, fillColor=fillColor, outline=outline), "verticalLocation": verticalLocation, "position": position} self.paintSocket(self.getSocket(name))
[docs] def getSocket(self, name): return self.sockets[name]["object"]
[docs] def getSocketsAt(self, verticalLocation): return filter(lambda s: s["verticalLocation"] == verticalLocation, self.sockets.values())
[docs] def getSocketCoords(self, name): socket = self.sockets[name] return self.getSocketCoordsAt(socket["verticalLocation"], socket["position"], self.countSockets(socket["verticalLocation"]))
[docs] def getSocketCoordsAt(self, verticalLocation, position=1, socketsCount=1): x1, y1, x2, y2 = self.getCorners() xc = (x2 + x1) / 2.0 yc = (y1 + y2) / 2.0 socketsGroupSize = (socketsCount - 1) * self.socketSeparation socketsGroupStart = xc - (socketsGroupSize / 2) x = socketsGroupStart + (position - 1) * self.socketSeparation if verticalLocation == "top": y = y1 elif verticalLocation == 'bottom': y = y2 elif verticalLocation == 'left': y = yc x = x1 else: y = yc x = x2 return x, y
[docs] def relocateSockets(self, verticalLocation, count): sockets = self.getSocketsAt(verticalLocation) for socket in sockets: o = socket["object"] x, y = self.getSocketCoordsAt(verticalLocation, socket["position"], count) o.moveTo(x, y)
[docs] def paintSocket(self, socket): # x,y=self.getSocketCoords(socket["name"]) socket.paintSocket()
[docs] def paintSockets(self): for name in self.sockets.keys(): self.paintSocket(self.getSocket(name))
[docs] def getDimensions(self): x, y, x2, y2 = self.canvas.bbox(self.id) return x2 - x, y2 - y
[docs] def move(self, dx, dy): if hasattr(self, "id"): self.canvas.move(self.id, dx, dy) self.x += dx self.y += dy for name in self.sockets.keys(): socket = self.sockets[name] socket["object"].move(dx, dy) for listenerFunc in self.listeners: listenerFunc(dx, dy)
[docs] def moveTo(self, x, y): """Move TextBox to a new position (x,y)""" self.move(x - self.x, y - self.y)
[docs] def addPositionListener(self, listenerFunc): self.listeners.append(listenerFunc)
[docs] def addSelectionListener(self, listenerFunc): self.selectionListeners.append(listenerFunc)
def _notifySelectionListeners(self, value): for listenerFunc in self.selectionListeners: listenerFunc(value)
[docs] def setSelected(self, value): self._selected = value bw = 0 bc = 'black' if value: bw = 2 self.lift() # bc = 'Firebrick' else: self.lower() self.canvas.itemconfig(self.id, width=bw, outline=bc) self._notifySelectionListeners(value)
[docs] def getSelected(self): return self._selected
[docs] def lift(self): self.canvas.lift(self.id)
[docs] def lower(self): self.canvas.lower(self.id)
[docs]class TextItem(Item): """This class will serve to paint and store rectangle boxes with some text. x and y are the coordinates of the center of this item""" def __init__(self, canvas, text, x, y, bgColor, textColor='black'): super(TextItem, self).__init__(canvas, x, y) self.bgColor = bgColor self.textColor = textColor self.text = text self.margin = 8 self.paint() def _paintBounds(self, x, y, w, h, fillColor): """ Subclasses should implement this method to paint the bounds to the text. Normally the bound are: rectangle or circles. Params: x, y: top left corner of the bounding box w, h: width and height of the box fillColor: color to fill the background Returns: should return the id of the created shape """ pass
[docs] def paint(self): """Paint the object in a specific position.""" self.id_text = self.canvas.create_text(self.x, self.y, text=self.text, justify=tk.CENTER, fill=self.textColor, font=self.canvas.getRunsFont()) xr, yr, w, h = self.canvas.bbox(self.id_text) m = self.margin self.id = self._paintBounds(xr - m, yr - m, w + m, h + m, fillColor=self.bgColor) self.canvas.lift(self.id_text)
[docs] def move(self, dx, dy): super(TextItem, self).move(dx, dy) self.canvas.move(self.id_text, dx, dy)
[docs] def lift(self): super(TextItem, self).lift() self.canvas.lift(self.id_text)
[docs] def lower(self): self.canvas.lower(self.id_text) super(TextItem, self).lower()
[docs]class TextBox(TextItem): def __init__(self, canvas, text, x, y, bgColor, textColor='black'): super(TextBox, self).__init__(canvas, text, x, y, bgColor, textColor) def _paintBounds(self, x, y, w, h, fillColor): return self.canvas.create_rectangle(x, y, w, h, fill=fillColor, outline=fillColor)
[docs]class RoundedTextBox(TextItem): def __init__(self, canvas, text, x, y, bgColor, textColor='black'): super(RoundedTextBox, self).__init__(canvas, text, x, y, bgColor, textColor) def _paintBounds(self, upperLeftX, upperLeftY, bottomRightX, bottomRightY, fillColor): d = 5 # When smooth=1, you define a straight segment by including its ends twice return self.canvas.create_polygon(upperLeftX + d + 1, upperLeftY, # 1 upperLeftX + d, upperLeftY, # 1 bottomRightX - d, upperLeftY, # 2 bottomRightX - d, upperLeftY, # 2 # bottomRightX-d+1,upperLeftY, #2b bottomRightX, upperLeftY + d - 1, # 3b bottomRightX, upperLeftY + d, # 3 bottomRightX, upperLeftY + d, # 3 bottomRightX, bottomRightY - d, # 4 bottomRightX, bottomRightY - d, # 4 bottomRightX - d, bottomRightY, # 5 bottomRightX - d, bottomRightY, # 5 upperLeftX + d, bottomRightY, # 6 upperLeftX + d, bottomRightY, # 6 # upperLeftX+d-1,bottomRightY, #6b upperLeftX, bottomRightY - d + 1, # 7b upperLeftX, bottomRightY - d, # 7 upperLeftX, bottomRightY - d, # 7 upperLeftX, upperLeftY + d, # 8 upperLeftX, upperLeftY + d, # 8 # upperLeftX, upperLeftY+d-1, #8b upperLeftX + d - 1, upperLeftY, # 1b fill=fillColor, outline='black', smooth=1)
[docs] def getDimensions(self): return self.canvas.bbox(self.id)
[docs]class TextCircle(TextItem): def __init__(self, canvas, text, x, y, bgColor, textColor='black'): super(TextCircle, self).__init__(canvas, text, x, y, bgColor, textColor) def _paintBounds(self, x, y, w, h, fillColor): return self.canvas.create_oval(x, y, w, h, fill=fillColor)
# This are not used and depends on xmippLib # class ImageBox(Item): # def __init__(self, canvas, imgPath, x=0, y=0, text=None): # Item.__init__(self, canvas, x, y) # # Create the image # from pyworkflow.gui import getImage, getImageFromPath # # if imgPath is None: # self.image = getImage('no-image.gif') # else: # self.image = getImageFromPath(imgPath) # # if text is not None: # self.label = tk.Label(canvas, image=self.image, text=text, # compound=tk.TOP, bg='gray') # self.id = self.canvas.create_window(x, y, window=self.label) # self.label.bind('<Button-1>', self._onClick) # else: # self.id = self.canvas.create_image(x, y, image=self.image) # # # def setSelected(self, value): #Ignore selection highlight # pass # # def _onClick(self, e=None): # pass
[docs]class Connector(Item): """ Default connector has no graphical representation (hence, it'ss invisible). Subclasses offer different looks""" def __init__(self, canvas, x, y, name): super(Connector, self).__init__(canvas, x, y) self.name = name
[docs] def paintSocket(self): """Should be implemented by the subclasses""" pass
[docs] def paintPlug(self, canvas, x, y): """Should be implemented by the subclasses""" pass
[docs] def move(self, dx, dy): super(Connector, self).move(dx, dy) if hasattr(self, "socketId"): self.canvas.move(self.socketId, dx, dy) if hasattr(self, "plugId"): self.canvas.move(self.plugId, dx, dy)
[docs]class ColoredConnector(Connector): def __init__(self, canvas, x, y, name, fillColor=DEFAULT_CONNECTOR_FILL, outline=DEFAULT_CONNECTOR_OUTLINE): super(ColoredConnector, self).__init__(canvas, x, y, name) self.fillColor = fillColor self.outline = outline
[docs]class RoundConnector(ColoredConnector): radius = 3
[docs] def paintSocket(self): self.socketId = self.canvas.create_oval(self.x - self.radius, self.y - self.radius, self.x + self.radius, self.y + self.radius, outline=self.outline)
[docs] def paintPlug(self): self.plugId = self.canvas.create_oval(self.x - self.radius, self.y - self.radius, self.x + self.radius, self.y + self.radius, fill=self.fillColor, width=0)
[docs]class SquareConnector(ColoredConnector): halfside = 3
[docs] def paintSocket(self): self.socketId = self.canvas.create_rectangle(self.x - self.halfside, self.y - self.halfside, self.x + self.halfside, self.y + self.halfside, outline=self.outline)
[docs] def paintPlug(self): self.plugId = self.canvas.create_rectangle(self.x - self.halfside, self.y - self.halfside, self.x + self.halfside, self.y + self.halfside, fill=self.fillColor, width=0)
# !!!! other figures: half circle, diamond...
[docs]class Oval: """Oval or circle""" def __init__(self, canvas, x, y, radio, color='green', anchor=None): self.anchor = anchor self.X, self.Y = x, y self.radio = radio self.color = color self.canvas = canvas anchor.addPositionListener(self.updateSrc) anchor.addSelectionListener(self.selectionListener) self.id = None self.paint()
[docs] def paint(self): if self.id: self.canvas.delete(self.id) self.id = self.canvas.create_oval(self.X, self.Y, self.X + self.radio, self.Y + self.radio, fill=self.color, outline='black')
# self.canvas.tag_raise(self.id)
[docs] def updateSrc(self, dx, dy): self.X += dx self.Y += dy self.paint()
[docs] def selectionListener(self, value): if value: self.canvas.lift(self.id)
[docs]class Rectangle: def __init__(self, canvas, x, y, width, height=None, color='green', anchor=None): self.anchor = anchor self.X, self.Y = x, y self.width = width self.height = height or width self.color = color self.canvas = canvas anchor.addPositionListener(self.updateSrc) anchor.addSelectionListener(self.selectionListener) self.id = None self.paint()
[docs] def paint(self): if self.id: self.canvas.delete(self.id) self.id = self.canvas.create_rectangle(self.X, self.Y, self.X + self.width, self.Y + self.height, fill=self.color, outline=self.color)
# self.canvas.tag_raise(self.id)
[docs] def updateSrc(self, dx, dy): self.X += dx self.Y += dy self.paint()
[docs] def selectionListener(self, value): if value: self.canvas.lift(self.id)
[docs]class Edge: """Edge between two objects""" def __init__(self, canvas, source, dest): self.source = source self.dest = dest self.srcX, self.srcY = source.x, source.y self.dstX, self.dstY = dest.x, dest.y self.canvas = canvas source.addPositionListener(self.updateSrc) dest.addPositionListener(self.updateDst) source.addSelectionListener(self.updateColor) dest.addSelectionListener(self.updateColor) self.id = None self.paint()
[docs] def paint(self): # coords = findClosestConnectors(self.source,self.dest) coords = getConnectors(self.source, self.dest) if coords: c1Coords, c2Coords = coords if self.id: self.canvas.delete(self.id) lineColor = '#ccc' lineWidth = 1 if not self.canvas.multipleItemsSelected(): if self.dest.getSelected(): lineColor = '#000' lineWidth = 2 elif self.source.getSelected(): lineColor = '#b22222' lineWidth = 2 else: if self.dest.getSelected() and self.source.getSelected(): lineColor = '#000' lineWidth = 2 self.id = self.canvas.create_line(c1Coords[0], c1Coords[1], c2Coords[0], c2Coords[1], width=lineWidth, fill=lineColor) self.canvas.tag_lower(self.id)
[docs] def updateSrc(self, dx, dy): self.srcX += dx self.srcY += dy self.paint()
[docs] def updateDst(self, dx, dy): self.dstX += dx self.dstY += dy self.paint()
[docs] def updateColor(self, value): self.paint()
# !!!! Interaction: allow to reconnect cables dynamically # !!!! Antialiasing for the line - it seems TkInter does not support antialiasing... # Although Tk 8.5 supports anti-aliasing if the Cairo library is installed: # @see http://wiki.tcl.tk/10630
[docs]class Cable: def __init__(self, canvas, src, srcConnector, dst, dstConnector): self.id = None self.canvas = canvas self.srcPlug = src.getSocket(srcConnector) self.dstPlug = dst.getSocket(dstConnector) self.srcX = self.srcPlug.x self.srcY = self.srcPlug.y self.dstX, self.dstY = dst.getSocketCoords(dstConnector) src.addPositionListener(self.srcMoved) dst.addPositionListener(self.dstMoved) self.paint()
[docs] def srcMoved(self, dx, dy): self.srcX = self.srcX + dx self.srcY = self.srcY + dy self.updateCoords()
[docs] def updateCoords(self): self.canvas.coords(self.id, self.srcX, self.srcY, self.dstX, self.dstY)
[docs] def dstMoved(self, dx, dy): self.dstX = self.dstX + dx self.dstY = self.dstY + dy self.updateCoords()
[docs] def paint(self): self.id = self.canvas.create_line(self.srcX, self.srcY, self.dstX, self.dstY, width=2) self.canvas.tag_lower(self.id) self.paintPlugs()
[docs] def paintPlugs(self): self.srcPlug.paintPlug() self.dstPlug.paintPlug()
if __name__ == '__main__': root = tk.Tk() canvas = Canvas(root, width=800, height=600) canvas.frame.grid(row=0, column=0, sticky='nsew') root.grid_columnconfigure(0, weight=1) root.grid_rowconfigure(0, weight=1) def canvasExample1(): tb1 = canvas.createTextCircle("Project", 100, 100, "blue") tb2 = canvas.createTextbox("This is an intentionally quite big, big box," "\nas you may appreciate looking carefully" "\nat it,\nas many times\nas you might need", 300, 200) tb2.addSocket("output1", RoundConnector, "bottom", fillColor="green") tb2.addSocket("output2", SquareConnector, "bottom", fillColor="yellow") tb2.addSocket("output3", SquareConnector, "bottom", fillColor="blue") tb3 = canvas.createRoundedTextbox("another one\n", 100, 200, "red") tb4 = canvas.createRoundedTextbox("tb4", 300, 300, "yellow") tb4.addSocket("input1", SquareConnector, "top", outline="red") tb5 = canvas.createTextCircle("tb5", 400, 300, "grey") tb5.addSocket("input1", SquareConnector, "top") e1 = canvas.createEdge(tb1, tb2) e2 = canvas.createEdge(tb1, tb3) c1 = canvas.createCable(tb2, "output2", tb4, "input1") c2 = canvas.createCable(tb2, "output3", tb5, "input1") tb3.moveTo(100, 300) canvasExample1() root.mainloop()