#!/usr/bin/env python # -*- coding: utf-8 -*- # drag_helper.py # Copyright (c) 2016-2020, Richard Gerum # # This file is part of Pylustrator. # # Pylustrator 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. # # Pylustrator 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 Pylustrator. If not, see <http://www.gnu.org/licenses/> from __future__ import division, print_function import numpy as np import matplotlib.pyplot as plt from matplotlib.artist import Artist from matplotlib.figure import Figure from matplotlib.text import Text from matplotlib.patches import Rectangle, Ellipse from matplotlib.backend_bases import MouseEvent, KeyEvent from typing import Sequence from .snap import TargetWrapper, getSnaps, checkSnaps, checkSnapsActive, SnapBase from .change_tracker import ChangeTracker DIR_X0 = 1 DIR_Y0 = 2 DIR_X1 = 4 DIR_Y1 = 8 class GrabFunctions(object): """ basic functionality used by all grabbers """ figure = None target = None dir = None snaps = None got_artist = False def __init__(self, parent, dir: int, no_height=False): self.figure = parent.figure self.parent = parent self.dir = dir self.snaps = [] self.no_height = no_height def on_motion(self, evt: MouseEvent): """ callback when the object is moved """ if self.got_artist: self.movedEvent(evt) self.moved = True def button_press_event(self, evt: MouseEvent): """ when the mouse is pressed """ self.got_artist = True self.moved = False self._c1 = self.figure.canvas.mpl_connect('motion_notify_event', self.on_motion) self.clickedEvent(evt) def button_release_event(self, event: MouseEvent): """ when the mouse is released """ if self.got_artist: self.got_artist = False self.figure.canvas.mpl_disconnect(self._c1) self.releasedEvent(event) def clickedEvent(self, event: MouseEvent): """ when the mouse is clicked """ self.parent.start_move() self.mouse_xy = (event.x, event.y) for s in self.snaps: s.remove() self.snaps = [] self.snaps = getSnaps(self.targets, self.dir, no_height=self.no_height) def releasedEvent(self, event: MouseEvent): """ when the mouse is released """ for snap in self.snaps: snap.remove() self.snaps = [] self.parent.end_move() def movedEvent(self, event: MouseEvent): """ when the mouse is moved """ if len(self.targets) == 0: return dx = event.x - self.mouse_xy[0] dy = event.y - self.mouse_xy[1] keep_aspect = ("control" in event.key.split("+") if event.key is not None else False) ignore_snaps = ("shift" in event.key.split("+") if event.key is not None else False) self.parent.move([dx, dy], self.dir, self.snaps, keep_aspect_ratio=keep_aspect, ignore_snaps=ignore_snaps) class GrabbableRectangleSelection(GrabFunctions): grabbers = None def addGrabber(self, x: float, y: float, dir: int, GrabberClass: object): # add a grabber object at the given coordinates self.grabbers.append(GrabberClass(self, x, y, dir)) def __init__(self, figure: Figure): self.grabbers = [] pos = [0, 0, 0, 0] self.positions = np.array(pos, dtype=float) self.p1 = self.positions[:2] self.p2 = self.positions[2:] self.figure = figure GrabFunctions.__init__(self, self, DIR_X0 | DIR_X1 | DIR_Y0 | DIR_Y1, no_height=True) self.addGrabber(0, 0, DIR_X0 | DIR_Y0, GrabberGenericRound) self.addGrabber(0.5, 0, DIR_Y0, GrabberGenericRectangle) self.addGrabber(1, 1, DIR_X1 | DIR_Y1, GrabberGenericRound) self.addGrabber(1, 0.5, DIR_X1, GrabberGenericRectangle) self.addGrabber(0, 1, DIR_X0 | DIR_Y1, GrabberGenericRound) self.addGrabber(0.5, 1, DIR_Y1, GrabberGenericRectangle) self.addGrabber(1, 0, DIR_X1 | DIR_Y0, GrabberGenericRound) self.addGrabber(0, 0.5, DIR_X0, GrabberGenericRectangle) self.c4 = self.figure.canvas.mpl_connect('key_press_event', self.keyPressEvent) self.targets = [] self.targets_rects = [] self.hide_grabber() def add_target(self, target: Artist): """ add an artist to the selection """ target = TargetWrapper(target) new_points = np.array(target.get_positions()) if len(new_points) == 0: return self.targets.append(target) x0, y0, x1, y1 = np.min(new_points[:, 0]), np.min(new_points[:, 1]), np.max(new_points[:, 0]), np.max( new_points[:, 1]) rect1 = Rectangle((x0, y0), x1 - x0, y1 - y0, picker=False, figure=self.figure, linestyle="-", edgecolor="w", facecolor="#FFFFFF00", zorder=900, label="_rect for %s" % str(target)) rect2 = Rectangle((x0, y0), x1 - x0, y1 - y0, picker=False, figure=self.figure, linestyle="--", edgecolor="k", facecolor="#FFFFFF00", zorder=900, label="_rect2 for %s" % str(target)) self.figure.patches.append(rect1) self.figure.patches.append(rect2) self.targets_rects.append(rect1) self.targets_rects.append(rect2) self.update_extent() def update_extent(self): """ updates the extend of the selection to all the selected elements """ points = None for target in self.targets: new_points = np.array(target.get_positions()) if points is None: points = new_points else: points = np.concatenate((points, new_points)) for grabber in self.grabbers: grabber.targets = self.targets self.positions[0] = np.min(points[:, 0]) self.positions[1] = np.min(points[:, 1]) self.positions[2] = np.max(points[:, 0]) self.positions[3] = np.max(points[:, 1]) if self.positions[2]-self.positions[0] < 0.01: self.positions[0], self.positions[2] = self.positions[0] - 0.01, self.positions[0] + 0.01 if self.positions[3]-self.positions[1] < 0.01: self.positions[1], self.positions[3] = self.positions[1] - 0.01, self.positions[1] + 0.01 if self.do_target_scale(): self.update_grabber() else: self.hide_grabber() def align_points(self, mode: str): """ a function to apply the alignment options, e.g. align all selected elements at the top or with equal spacing. """ if len(self.targets) == 0: return def align(y: int, func: callable): centers = [] for target in self.targets: new_points = np.array(target.get_positions()) centers.append(func(new_points[:, y])) new_center = func(self.positions[y::2]) for index, target in enumerate(self.targets): new_points = np.array(target.get_positions()) new_points[:, y] += new_center - centers[index] target.set_positions(new_points) self.update_extent() def distribute(y: int): sizes = [] positions = [] for target in self.targets: new_points = np.array(target.get_positions()) sizes.append(np.diff(new_points[:, y])[0]) positions.append(np.min(new_points[:, y])) order = np.argsort(positions) spaces = np.diff(self.positions[y::2])[0] - np.sum(sizes) spaces /= max([(len(self.targets)-1), 1]) pos = np.min(self.positions[y::2]) for index in order: target = self.targets[index] new_points = np.array(target.get_positions()) new_points[:, y] += pos - np.min(new_points[:, y]) target.set_positions(new_points) pos += sizes[index] + spaces if mode == "center_x": align(0, np.mean) if mode == "left_x": align(0, np.min) if mode == "right_x": align(0, np.max) if mode == "center_y": align(1, np.mean) if mode == "bottom_y": align(1, np.min) if mode == "top_y": align(1, np.max) if mode == "distribute_x": distribute(0) if mode == "distribute_y": distribute(1) def update_selection_rectangles(self): """ update the selection visualisation """ if len(self.targets) == 0: return for index, target in enumerate(self.targets): new_points = np.array(target.get_positions()) for i in range(2): rect = self.targets_rects[index*2+i] rect.set_xy(new_points[0]) rect.set_width(new_points[1][0] - new_points[0][0]) rect.set_height(new_points[1][1] - new_points[0][1]) self.update_extent() def remove_target(self, target: Artist): """ remove an artist from the current selection """ targets_non_wrapped = [t.target for t in self.targets] if target not in targets_non_wrapped: return index = targets_non_wrapped.index(target) self.targets.pop(index) rect1 = self.targets_rects.pop(index*2) rect2 = self.targets_rects.pop(index*2) self.figure.patches.remove(rect1) self.figure.patches.remove(rect2) if len(self.targets) == 0: self.clear_targets() else: self.update_extent() def update_grabber(self): """ update the position of the grabber elements """ if self.do_target_scale(): for grabber in self.grabbers: grabber.updatePos() else: self.hide_grabber() def hide_grabber(self): """ hide the grabber elements """ for grabber in self.grabbers: grabber.set_xy((-100, -100)) def clear_targets(self): """ remove all elements from the selection """ for rect in self.targets_rects: self.figure.patches.remove(rect) self.targets_rects = [] self.targets = [] self.hide_grabber() def do_target_scale(self) -> bool: """ if any of the elements in the selection allows scaling """ return np.any([target.do_scale for target in self.targets]) def do_change_aspect_ratio(self) -> bool: """ if any of the element sin the selection wants to perserve its aspect ratio """ return np.any([target.fixed_aspect for target in self.targets]) def width(self) -> float: """ the width of the current selection """ return (self.p2-self.p1)[0] def height(self) -> float: """ the height of the current selection """ return (self.p2-self.p1)[1] def size(self) -> (float, float): """ the size of the current selection (width and height)""" return self.p2-self.p1 def get_trans_matrix(self): """ the transformation matrix for the current displacement and scaling of the selection """ x, y = self.p1 w, h = self.size() return np.array([[w, 0, x], [0, h, y], [0, 0, 1]], dtype=float) def get_inv_trans_matrix(self): """ the inverse transformation for the current displacement and scaling of the selection """ x, y = self.p1 w, h = self.size() return np.array([[1./w, 0, -x/w], [0, 1./h, -y/h], [0, 0, 1]], dtype=float) def transform(self, pos: Sequence) -> np.ndarray: """ apply the current transformation to a point """ return np.dot(self.get_trans_matrix(), [pos[0], pos[1], 1.0]) def inv_transform(self, pos: Sequence) -> np.ndarray: """ apply the inverse current transformation to a point """ return np.dot(self.get_inv_trans_matrix(), [pos[0], pos[1], 1.0]) def get_pos(self, pos: Sequence) -> np.ndarray: """ transform a point """ return self.transform(pos) def get_save_point(self) -> callable: """ gather the current positions in a restore point for the undo function """ targets = [target.target for target in self.targets] positions = [target.get_positions() for target in self.targets] def undo(): self.clear_targets() for target, pos in zip(targets, positions): target = TargetWrapper(target) target.set_positions(pos) self.add_target(target.target) return undo def start_move(self): """ start to move a grabber """ self.start_p1 = self.p1.copy() self.start_p2 = self.p2.copy() self.hide_grabber() self.store_start = self.get_save_point() def end_move(self): """ a grabber move stopped """ self.update_grabber() self.figure.canvas.draw() self.store_end = self.get_save_point() self.figure.change_tracker.addEdit([self.store_start, self.store_end]) def addOffset(self, pos: Sequence, dir: int, keep_aspect_ratio: bool = True): """ move the whole selection (e.g. for the use of the arrow keys) """ pos = list(pos) self.old_inv_transform = self.get_inv_trans_matrix() if (keep_aspect_ratio or self.do_change_aspect_ratio()) and not (dir & DIR_X0 and dir & DIR_X1 and dir & DIR_Y0 and dir & DIR_Y1): if (dir & DIR_X0 and dir & DIR_Y0) or (dir & DIR_X1 and dir & DIR_Y1): dx = pos[1]*self.width()/self.height() dy = pos[0]*self.height()/self.width() if abs(dx) < abs(dy): pos[0] = dx else: pos[1] = dy elif (dir & DIR_X0 and dir & DIR_Y1) or (dir & DIR_X1 and dir & DIR_Y0): dx = -pos[1]*self.width()/self.height() dy = -pos[0]*self.height()/self.width() if abs(dx) < abs(dy): pos[0] = dx else: pos[1] = dy elif dir & DIR_X0 or dir & DIR_X1: dy = pos[0]*self.height()/self.width() if dir & DIR_X0: self.p1[1] = self.start_p1[1] + dy/2 self.p2[1] = self.start_p2[1] - dy/2 else: self.p1[1] = self.start_p1[1] - dy / 2 self.p2[1] = self.start_p2[1] + dy / 2 elif dir & DIR_Y0 or dir & DIR_Y1: dx = pos[1]*self.width()/self.height() if dir & DIR_Y0: self.p1[0] = self.start_p1[0] + dx/2 self.p2[0] = self.start_p2[0] - dx/2 else: self.p1[0] = self.start_p1[0] - dx / 2 self.p2[0] = self.start_p2[0] + dx / 2 if dir & DIR_X0: self.p1[0] = self.start_p1[0] + pos[0] if dir & DIR_X1: self.p2[0] = self.start_p2[0] + pos[0] if dir & DIR_Y0: self.p1[1] = self.start_p1[1] + pos[1] if dir & DIR_Y1: self.p2[1] = self.start_p2[1] + pos[1] transform = np.dot(self.get_trans_matrix(), self.old_inv_transform) for target in self.targets: self.transform_target(transform, target) for rect in self.targets_rects: self.transform_target(transform, TargetWrapper(rect)) def move(self, pos: Sequence[float], dir: int, snaps: Sequence[SnapBase], keep_aspect_ratio: bool = False, ignore_snaps: bool = False): """ called from a grabber to move the selection. """ self.addOffset(pos, dir, keep_aspect_ratio) if not ignore_snaps: offx, offy = checkSnaps(snaps) self.addOffset((pos[0]-offx, pos[1]-offy), dir, keep_aspect_ratio) offx, offy = checkSnaps(self.snaps) checkSnapsActive(snaps) self.figure.canvas.draw() def apply_transform(self, transform: np.ndarray, point: Sequence[float]): """ apply the given transformation to a point""" point = np.array(point) point = np.hstack((point, np.ones((point.shape[0], 1)))).T return np.dot(transform, point)[:2].T def transform_target(self, transform: np.ndarray, target: TargetWrapper): """ transform the position of an artist. """ points = target.get_positions() points = self.apply_transform(transform, points) target.set_positions(points) def keyPressEvent(self, event: KeyEvent): """ when a key is pressed. Arrow keys move the selection, Pageup/down movein z """ #if not self.selected: # return # move last axis in z order if event.key == 'pagedown': for target in self.targets: target.target.set_zorder(target.target.get_zorder() - 1) self.figure.change_tracker.addChange(target.target, ".set_zorder(%d)" % target.target.get_zorder()) self.figure.canvas.draw() if event.key == 'pageup': for target in self.targets: target.target.set_zorder(target.target.get_zorder() + 1) self.figure.change_tracker.addChange(target.target, ".set_zorder(%d)" % target.target.get_zorder()) self.figure.canvas.draw() if event.key == 'left': self.start_move() self.addOffset((-1, 0), self.dir) self.end_move() if event.key == 'right': self.start_move() self.addOffset((+1, 0), self.dir) self.end_move() if event.key == 'down': self.start_move() self.addOffset((0, -1), self.dir) self.end_move() if event.key == 'up': self.start_move() self.addOffset((0, +1), self.dir) self.end_move() if event.key == "delete": for target in self.targets[::-1]: self.figure.change_tracker.removeElement(target.target) self.figure.canvas.draw() #print("event", event.key) class DragManager: """ a class to manage the selection and the moving of artists in a figure """ selected_element = None grab_element = None def __init__(self, figure: Figure): self.figure = figure self.figure.figure_dragger = self self.figure.canvas.mpl_disconnect(self.figure.canvas.manager.key_press_handler_id) self.activate() # make all the subplots pickable for index, axes in enumerate(self.figure.axes): axes.set_picker(True) leg = axes.get_legend() if leg: self.make_dragable(leg) for text in axes.texts: self.make_dragable(text) for attribute_name in ["title", "_left_title", "_right_title"]: text = getattr(axes, attribute_name, None) if text is not None: self.make_dragable(text) for patch in axes.patches: self.make_dragable(patch) self.make_dragable(axes.xaxis.get_label()) self.make_dragable(axes.yaxis.get_label()) self.make_dragable(axes) for text in self.figure.texts: self.make_dragable(text) for patch in self.figure.patches: self.make_dragable(patch) self.selection = GrabbableRectangleSelection(figure) self.figure.selection = self.selection self.change_tracker = ChangeTracker(figure) self.figure.change_tracker = self.change_tracker def activate(self): """ activate the interaction callbacks from the figure """ self.c3 = self.figure.canvas.mpl_connect('button_release_event', self.button_release_event0) self.c2 = self.figure.canvas.mpl_connect('button_press_event', self.button_press_event0) self.c4 = self.figure.canvas.mpl_connect('key_press_event', self.key_press_event) def deactivate(self): """ deactivate the interaction callbacks from the figure """ self.figure.canvas.mpl_disconnect(self.c3) self.figure.canvas.mpl_disconnect(self.c2) self.figure.canvas.mpl_disconnect(self.c4) self.selection.clear_targets() self.selected_element = None self.on_select(None, None) self.figure.canvas.draw() def make_dragable(self, target: Artist): """ make an artist draggable """ target.set_picker(True) if isinstance(target, Text): target.set_bbox(dict(facecolor="none", edgecolor="none")) def get_picked_element(self, event: MouseEvent, element: Artist = None, picked_element: Artist = None, last_selected: Artist = None): """ get the picked element that an event refers to. To implement selection of elements at the back with multiple clicks. """ # start with the figure if element is None: element = self.figure finished = False # iterate over all children for child in sorted(element.get_children(), key=lambda x: x.get_zorder()): # check if the element is contained in the event and has an active dragger #if child.contains(event)[0] and ((getattr(child, "_draggable", None) and getattr(child, "_draggable", # None).connected) or isinstance(child, GrabberGeneric) or isinstance(child, GrabbableRectangleSelection)): if child.get_visible() and child.contains(event)[0] and (child.pickable() or isinstance(child, GrabberGeneric)) and not (child.get_label() is not None and child.get_label().startswith("_")): # if the element is the last selected, finish the search if child == last_selected: return picked_element, True # use this element as the current best matching element picked_element = child # iterate over the children's children picked_element, finished = self.get_picked_element(event, child, picked_element, last_selected=last_selected) # if the subcall wants to finish, just break the loop if finished: break return picked_element, finished def button_release_event0(self, event: MouseEvent): """ when the mouse button is released """ # release the grabber if self.grab_element: self.grab_element.button_release_event(event) self.grab_element = None # or notify the selected element elif len(self.selection.targets): self.selection.button_release_event(event) def button_press_event0(self, event: MouseEvent): """ when the mouse button is pressed """ if event.button == 1: last = self.selection.targets[-1] if len(self.selection.targets) else None contained = np.any([t.target.contains(event)[0] for t in self.selection.targets]) # recursively iterate over all elements picked_element, _ = self.get_picked_element(event, last_selected=last if event.dblclick else None) # if the element is a grabber, store it if isinstance(picked_element, GrabberGeneric): self.grab_element = picked_element # if not, we want to keep our selected element, if the click was in the area of the selected element elif len(self.selection.targets) == 0 or not contained or event.dblclick: self.select_element(picked_element, event) contained = True # if we have a grabber, notify it if self.grab_element: self.grab_element.button_press_event(event) # if not, notify the selected element elif contained: self.selection.button_press_event(event) def select_element(self, element: Artist, event: MouseEvent = None): """ select an artist in a figure """ # do nothing if it is already selected if element == self.selected_element: return # if there was was previously selected element, deselect it if self.selected_element is not None: self.on_deselect(event) # if there is a new element, select it self.on_select(element, event) self.selected_element = element self.figure.canvas.draw() def on_deselect(self, event: MouseEvent): """ deselect currently selected artists""" modifier = "shift" in event.key.split("+") if event is not None and event.key is not None else False # only if the modifier key is not used if not modifier: self.selection.clear_targets() def on_select(self, element: Artist, event: MouseEvent): """ when an artist is selected """ if element is not None: self.selection.add_target(element) def key_press_event(self, event: KeyEvent): """ when a key is pressed """ # space: print code to restore current configuration if event.key == 'ctrl+s': self.figure.change_tracker.save() if event.key == "ctrl+z": self.figure.change_tracker.backEdit() if event.key == "ctrl+y": self.figure.change_tracker.forwardEdit() if event.key == "escape": self.selection.clear_targets() self.selected_element = None self.on_select(None, None) self.figure.canvas.draw() class GrabberGeneric(GrabFunctions): """ a generic grabber object to move a selection """ _no_save = True def __init__(self, parent: GrabbableRectangleSelection, x: float, y: float, dir: int): self._animated = True GrabFunctions.__init__(self, parent, dir) self.pos = (x, y) self.updatePos() def get_xy(self): return self.center def set_xy(self, xy: (float, float)): self.center = xy def getPos(self): x, y = self.get_xy() return self.transform.transform((x, y)) def updatePos(self): self.set_xy(self.parent.get_pos(self.pos)) def applyOffset(self, pos: (float, float), event: MouseEvent): self.set_xy((self.ox+pos[0], self.oy+pos[1])) class GrabberGenericRound(Ellipse, GrabberGeneric): """ a rectangle with a round appearance """ d = 10 def __init__(self, parent: GrabbableRectangleSelection, x: float, y: float, dir: int): GrabberGeneric.__init__(self, parent, x, y, dir) Ellipse.__init__(self, (0, 0), self.d, self.d, picker=True, figure=parent.figure, edgecolor="k", facecolor="r", zorder=1000, label="grabber") self.figure.patches.append(self) self.updatePos() class GrabberGenericRectangle(Rectangle, GrabberGeneric): """ a rectangle with a square appearance """ d = 10 def __init__(self, parent: GrabbableRectangleSelection, x: float, y: float, dir: int): # somehow the original "self" rectangle does not show up in the current matplotlib version, therefore this doubling self.rect = Rectangle((0, 0), self.d, self.d, figure=parent.figure, edgecolor="k", facecolor="r", zorder=1000, label="grabber") self.rect._no_save = True parent.figure.patches.append(self.rect) Rectangle.__init__(self, (0, 0), self.d, self.d, picker=True, figure=parent.figure, edgecolor="k", facecolor="r", zorder=1000, label="grabber") GrabberGeneric.__init__(self, parent, x, y, dir) self.figure.patches.append(self) self.updatePos() def get_xy(self): xy = Rectangle.get_xy(self) return xy[0] + self.d / 2, xy[1] + self.d / 2 def set_xy(self, xy: (float, float)): Rectangle.set_xy(self, (xy[0] - self.d / 2, xy[1] - self.d / 2)) self.rect.set_xy((xy[0] - self.d / 2, xy[1] - self.d / 2))