Source code for rkviewer.canvas.canvas

"""The main canvas panel.

This handles user input actions, updates the model, and performs redraws after the model is updated.
"""
# pylint: disable=maybe-no-member
from collections import defaultdict
from contextlib import contextmanager
import copy
import enum
from itertools import chain
import logging
from logging import Logger
import time
import typing
from typing import Collection, DefaultDict, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
from commentjson.commentjson import JSONLibraryException
from marshmallow.exceptions import ValidationError

from sortedcontainers import SortedKeyList
import wx
import os
import math

from ..config import get_setting, get_theme, pop_settings_err
from ..events import (
    CanvasDidUpdateEvent,
    DidCommitDragEvent, DidDeleteEvent, DidMoveNodesEvent,
    DidPaintCanvasEvent,
    SelectionDidUpdateEvent,
    bind_handler,
    post_event,
)
from ..mvc import IController
from ..utils import even_round, opacity_mul, resource_path
from .data import Compartment, Node, Reaction, ReactionBezier, compute_centroid, init_bezier
from .elements import BezierHandle, CanvasElement, CompartmentElt, Layer, NodeElement, ReactionCenter, ReactionElement, SelectBox, layer_above
from .geometry import (
    Rect, Vec2, circle_bounds,
    clamp_rect_pos, get_bounding_rect,
    padded_rect,
    rects_overlap,
    pt_in_rect,
)
from .overlays import CanvasOverlay, Minimap
from .state import InputMode, cstate
from .utils import Observer, SetSubject, default_handle_positions
from .utils import draw_rect, get_nodes_by_idx


BOUNDS_EPS = 0
"""The padding around the canvas to ensure nodes are not moved out of bounds due to floating pont
issues.
"""
BOUNDS_EPS_VEC = Vec2.repeat(BOUNDS_EPS)
"""2D bounds vector formed from BOUNDS_EPS"""


[docs]class Alignment(enum.Enum): """Alignment""" LEFT = 0 RIGHT = 1 CENTER = 2 TOP = 3 BOTTOM = 4 MIDDLE = 5 GRID = 6 HORIZONTAL = 7 #: Arrange into a row VERTICAL = 8 #: Arrange into a column
DBL_CLICK_TIMER = 123 # Don't use ScrolledPanel since Canvas does not scroll conventionally. class Canvas(wx.ScrolledWindow): """The main window onto which nodes, reactions, etc. will be drawn. Attributes: MIN_ZOOM_LEVEL: The minimum zoom level the user is allowed to reach. See SetZoomLevel() for more detail. MAX_ZOOM_LEVEL: The maximum zoom level the user is allowed to reach. NODE_LAYER: The node layer. REACTION_LAYER: The reaction layer. SELECT_BOX_LAYER: The layer for the select box. controller: The associated controller instance. realsize: The actual, total size of canvas, including the part offscreen. net_index: The index of the current network. Rightn now it can be zero. sel_nodes_idx: The set of indices of the currently selected nodes. sel_reactions_idx: The set of indices of the currently selected reactions. sel_compartments_idx: The set of indices of the currently selected compartments. drag_sel_nodes_idx: The set of indices tentatively selected during dragging. This is added to sel_nodes_idx only after the user has stopped drag-selecting. drag_sel_comps_idx: See drag_sel_nodes_idx but for compartments hovered_element: The element over which the mouse is hovering, or None. zoom_slider: The zoom slider widget. reaction_map: Maps node index to the set of reaction (indices) that it is in. """ MIN_ZOOM_LEVEL: int = -11 MAX_ZOOM_LEVEL: int = 11 NODE_LAYER = 1 REACTION_LAYER = 2 COMPARTMENT_LAYER = 3 DRAGGED_NODE_LAYER = 9 SELECT_BOX_LAYER = 10 HANDLE_LAYER = 11 MILLIS_PER_REFRESH = 16 # serves as framerate cap KEY_MOVE_STRIDE: int = 1 #: Number of pixels to move when the user presses an arrow key. #: Larger move stride for convenience; used when SHIFT is pressed. KEY_MOVE_LONG_STRIDE: int = 10 controller: IController realsize: Vec2 sel_nodes_idx: SetSubject sel_reactions_idx: SetSubject sel_compartments_idx: SetSubject drag_sel_nodes_idx: Set[int] drag_sel_rxns_idx: Set[int] drag_sel_comps_idx: Set[int] hovered_element: Optional[CanvasElement] dragged_element: Optional[CanvasElement] zoom_slider: wx.Slider reaction_map: DefaultDict[int, Set[int]] # maps node index to reactions it's part of logger: Logger #: Current network index. Right now this is always 0 since there is only one tab. _net_index: int _nodes: List[Node] #: List of Node instances. This contains data needed to render them. # TODO move this one to top docstring _reactions: List[Reaction] #: List of ReactionBezier instances. _compartments: List[Compartment] #: List of Compartment instances _node_elements: List[NodeElement] _reaction_elements: List[ReactionElement] _compartment_elements: List[CompartmentElt] _plugin_elements: Set[CanvasElement] _zoom_level: int #: The current zoom level. See SetZoomLevel() for more detail. #: The zoom scale. This always corresponds one-to-one with zoom_level. See property for detail. _reactant_idx: Set[int] #: The list of indices of the currently designated reactant nodes. _product_idx: Set[int] #: The list of indices of the currently designated product nodes _select_box: SelectBox #: The select box element. _minimap: Minimap #: The minimap overlay. _overlays: List[CanvasOverlay] #: The list of overlays. Used when processing click events. _drag_selecting: bool #: If currently dragging the selection rectangle. _drag_select_start: Vec2 #: The (logical) mouse position when the user started drag selecting. _drag_rect: Rect #: The current drag-selection rectangle. _reverse_status: Dict[str, int] #: Maps status string in .config.settings to its index. #: Flag for whether the mouse is currently outside of the root app window. _copied_nodes: List[Node] #: Copy of nodes currently in clipboard _accum_frames: int _last_fps_update: int _last_refresh: int #: When True, SelectionDidUpdate events are not fired. This is in case multiple such events #: are fired consecutively (e.g. both nodes and reactions changed), to make sure that only one #: event fires in total. _in_selection_group: bool #: bool to indicate whether a selection changed event was fired inside selection group. _selection_dirty: bool node_idx_map: Dict[int, Node] #: Maps node index to node reaction_idx_map: Dict[int, Reaction] #: Maps reaction index to reaction node_to_rxn: DefaultDict[int, Set[int]] comp_idx_map: Dict[int, Compartment] #: Maps compartment index to compartment sel_nodes: List[Node] #: Current list of selected nodes; cached for performance sel_reactions: List[Reaction] sel_comps: List[Compartment] #: Current list of selected comps; cached for performance drawing_drag: bool #: See self._UpdateSelectedLists() for more details def __init__(self, controller: IController, zoom_slider, *args, realsize: Tuple[int, int], **kw): # ensure the parent's __init__ is called super().__init__(*args, style=wx.DEFAULT_FRAME_STYLE & ~wx.MAXIMIZE_BOX ^ wx.RESIZE_BORDER, **kw) self.last_motion = 0 err = pop_settings_err() if err is not None: if isinstance(err, JSONLibraryException): message = 'Failed when parsing settings.json. Using default settings instead.\n\n' message += err.message else: message = 'Invalid settings in settings.json. Using default settings instead.\n\n' message += str(err) self.ShowWarningDialog(message) init_bezier() self.controller = controller self._net_index = 0 self._nodes = list() self._reactions = list() self._compartments = list() self._node_elements = list() self._reaction_elements = list() self._compartment_elements = list() # TODO document below self._model_elements = SortedKeyList(key=lambda e: e.layers) self._widget_elements = SortedKeyList(key=lambda e: e.layers) self._plugin_elements = set() self.hovered_element = None self.dragged_element = None self.node_to_rxn = defaultdict(set) self.logger = logging.getLogger('canvas') # prevent flickering self.SetDoubleBuffered(True) # events self.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown) self.Bind(wx.EVT_LEFT_UP, self.OnLeftUp) self.Bind(wx.EVT_RIGHT_UP, self.OnRightUp) self.Bind(wx.EVT_MOTION, self.OnMotion) self.Bind(wx.EVT_PAINT, self.OnPaint) self.Bind(wx.EVT_SCROLLWIN, self.OnScroll) self.Bind(wx.EVT_MOUSEWHEEL, self.OnMouseWheel) self.Bind(wx.EVT_LEAVE_WINDOW, self.OnLeaveWindow) self.Bind(wx.EVT_WINDOW_DESTROY, self.OnWindowDestroy) self.Bind(wx.EVT_IDLE, self.OnIdle) self.Bind(wx.EVT_ERASE_BACKGROUND, lambda _: None) # Don't erase background self.Bind(wx.EVT_CHAR_HOOK, self.OnChar) bind_handler(DidCommitDragEvent, self.OnDidCommitNodePositions) # state variables cstate.input_mode = InputMode.SELECT # Set to (0, 0) since this won't be used before it's updated once first self._dragged_rel_window = wx.Point() self._zoom_level = 0 self.realsize = Vec2(realsize) cstate.bounds = Rect(Vec2(), self.realsize) scroll_width = wx.SystemSettings.GetMetric(wx.SYS_VSCROLL_X) scroll_height = wx.SystemSettings.GetMetric(wx.SYS_HSCROLL_Y) self._scroll_off = Vec2(scroll_width, scroll_height) self.SetVirtualSize(*self.realsize) bounds = Rect(BOUNDS_EPS_VEC, self.realsize - BOUNDS_EPS_VEC) self._select_box = SelectBox(self, [], [], bounds, self.controller, self._net_index, Canvas.SELECT_BOX_LAYER) self.sel_nodes_idx = SetSubject() self.sel_reactions_idx = SetSubject() self.sel_compartments_idx = SetSubject() selection_obs = Observer(lambda _: self._SelectionChanged()) self.sel_nodes_idx.attach(selection_obs) self.sel_reactions_idx.attach(selection_obs) self.sel_compartments_idx.attach(selection_obs) self._reactant_idx = set() self._product_idx = set() self.zoom_slider = zoom_slider self.zoom_slider.SetRange(Canvas.MIN_ZOOM_LEVEL, Canvas.MAX_ZOOM_LEVEL) self.zoom_slider.SetBackgroundColour(get_theme('zoom_slider_bg')) self.GetParent().Bind(wx.EVT_SLIDER, self.OnSlider) # Set a placeholder value for position; we will set it later in SetOverlayPositions(). self._minimap = Minimap(pos=Vec2(), device_pos=Vec2(), width=200, realsize=self.realsize, window_size=Vec2(self.GetSize()), pos_callback=self.SetOriginPos) self._overlays = [self._minimap] self._drag_selecting = False self._drag_select_start = Vec2() self._drag_rect = Rect(Vec2(), Vec2()) self.drag_sel_nodes_idx = set() self.drag_sel_rxns_idx = set() self.drag_sel_comps_idx = set() self._status_bar = self.GetTopLevelParent().GetStatusBar() assert self._status_bar is not None, "Need to create status bar before creating canvas!" status_fields = get_setting('status_fields') assert status_fields is not None self._reverse_status = {name: i for i, (name, _) in enumerate(status_fields)} self._copied_nodes = list() wx.CallAfter(lambda: self.SetZoomLevel(0, Vec2(0, 0))) self._accum_frames = 0 self._cursor_logical_pos = None self._last_fps_update = 0 self._last_refresh = 0 cstate.input_mode_changed = self.InputModeChanged self.comp_index = 0 # Compartment of index; remove once controller implements compartments self.node_idx_map = dict() self.reaction_idx_map = dict() self.comp_idx_map = dict() self._nodes_floating = False self._in_selection_group = False self._selection_dirty = False self.SetOverlayPositions() self.sel_nodes = [] self.sel_reactions = [] self.sel_comps = [] self.drawing_drag = False self._dynamic_elements = set() self._static_bitmap = None self._dirty = True def OnWindowDestroy(self, evt): evt.Skip() def OnIdle(self, evt): # if self._static_bitmap is None: # self._RedrawDynamicToBuffer() self.LazyRefresh() def OnChar(self, evt): keycode = evt.GetKeyCode() offset: Vec2 if keycode == wx.WXK_LEFT: offset = Vec2(-1, 0) elif keycode == wx.WXK_RIGHT: offset = Vec2(1, 0) elif keycode == wx.WXK_UP: offset = Vec2(0, -1) elif keycode == wx.WXK_DOWN: offset = Vec2(0, 1) else: evt.Skip() return if wx.GetKeyState(wx.WXK_SHIFT): offset *= Canvas.KEY_MOVE_LONG_STRIDE else: offset *= Canvas.KEY_MOVE_STRIDE if len(self._select_box.nodes) != 0 or len(self._select_box.compartments) != 0: self._select_box.move_offset(offset) @property def select_box(self): return self._select_box @property def nodes(self): return self._nodes @property def reactions(self): return self._reactions @property def compartments(self): return self._compartments def InputModeChanged(self, val: InputMode): if val == InputMode.ADD_NODES: self.SetCursor(wx.Cursor(wx.CURSOR_CROSS)) else: self.SetCursor(wx.Cursor(wx.CURSOR_ARROW)) self._SetStatusText('mode', str(val)) @property def net_index(self): return self._net_index def ArrowTipChanged(self): for rea_el in self._reaction_elements: for bz in rea_el.bezier.dest_beziers: bz.arrow_tip_changed() self._RedrawDynamicToBuffer() def RegisterAllChildren(self, widget): """Connect all descendants of this widget to relevant events. wxPython does not propagate events like LEFT_UP and MOTION up to the parent of the window that received it. Therefore normally there is no way for DragDrop to detect a mouse event if it occurred on top of a child widget of window. This function solves this problem by recursively connecting all child widgets of window to trigger the DragDrop handlers. Note that whatever event registered here must do evt.Skip() so that the child itself can handle its event as well. This solution is from https://stackoverflow.com/a/27911300/9171534 """ if self != widget: widget.Connect(wx.ID_ANY, -1, wx.wxEVT_LEFT_UP, self.OnLeftUp) widget.Connect(wx.ID_ANY, -1, wx.wxEVT_MOTION, self.OnMotion) widget.Connect(wx.ID_ANY, -1, wx.wxEVT_LEAVE_WINDOW, self.OnLeaveWindow) for child in widget.GetChildren(): self.RegisterAllChildren(child) def _InWhichOverlay(self, device_pos: Vec2) -> Optional[CanvasOverlay]: """If position is within an overlay, return that overlay; otherwise return None. Note: If the position is within multiple overlays, return the latest added overlay, i.e. the overlay with the largest index in the _overlays list. Returns: An overlay if applicable, or None if not. """ # TODO right now this is hardcoded; in the future add List[CanvasOverlay] attribute if pt_in_rect(device_pos, Rect(self._minimap.device_pos, self._minimap.size)): return self._minimap return None def SetOverlayPositions(self): """Set the positions of the overlaid widgets. This should be called in OnPaint so that the overlaid widgets stay in the same relative position. """ # do all the minimap updates here, since this is simpler and less prone to bugs minimap_pos = Vec2(self.GetClientSize()) - self._minimap.size minimap_pos = minimap_pos.swapped(1, minimap_pos.y) self._minimap.device_pos = minimap_pos self._minimap.position = Vec2(self.CalcUnscrolledPosition(*minimap_pos.as_int())) self._minimap.window_pos = Vec2(self.CalcUnscrolledPosition(0, 0)) / cstate.scale self._minimap.window_size = Vec2(self.GetSize()) / cstate.scale self._minimap.realsize = self.realsize def CreateNodeElement(self, node: Node, layers: Layer) -> NodeElement: return NodeElement(node, self, layers) def CreateReactionElement(self, rxn: Reaction, layers: Layer) -> ReactionElement: snodes = [self.node_idx_map[idx] for idx in rxn.sources] tnodes = [self.node_idx_map[idx] for idx in rxn.targets] rb = ReactionBezier(rxn, snodes, tnodes) return ReactionElement(rxn, rb, self, layers, Canvas.HANDLE_LAYER) def CreateCompartmentElement(self, comp: Compartment) -> CompartmentElt: # NOTE the index of the compartment is used as its *secondary layer*, i.e. all compartments # share the same main layor (COMPARTMENT_LAYER), but to make sure the containing nodes # are rendered correctly, a secondary layer is applied to each compartment, taking its # index. This works because compartments with higher indices are added later and therefore # should be rendered on top. return CompartmentElt(comp, Canvas.COMPARTMENT_LAYER, comp.index) def Reset(self, nodes: List[Node], reactions: List[Reaction], compartments: List[Compartment]): """Update the list of nodes and apply the current scale.""" # destroy old elements for elt in chain(self._model_elements, self._widget_elements): if elt not in self._plugin_elements: elt.destroy() # cull removed indices node_idx = {n.index for n in nodes} rxn_idx = {r.index for r in reactions} comp_idx = {c.index for c in compartments} self.sel_nodes_idx.set_item(self.sel_nodes_idx.item_copy() & node_idx) new_sel_reactions = self.sel_reactions_idx.item_copy() & rxn_idx self.sel_reactions_idx.set_item(new_sel_reactions) self.sel_compartments_idx.set_item(self.sel_compartments_idx.item_copy() & comp_idx) self._reactant_idx &= node_idx self._product_idx &= node_idx # Update index maps self.node_idx_map = dict() for node in nodes: self.node_idx_map[node.index] = node self.reaction_idx_map = dict() for rxn in reactions: self.reaction_idx_map[rxn.index] = rxn self.comp_idx_map = dict() for comp in compartments: self.comp_idx_map[comp.index] = comp # Update reaction map self.node_to_rxn = defaultdict(set) for rxn in reactions: for nodei in chain(rxn.sources, rxn.targets): self.node_to_rxn[nodei].add(rxn.index) self._nodes = nodes self._reactions = reactions self._compartments = compartments # Don't clear hovered_element if it is SelectBox if not isinstance(self.hovered_element, SelectBox): self.hovered_element = None self.dragged_element = None self._compartment_elements = [self.CreateCompartmentElement(c) for c in compartments] # create node elements and assign the correct layers to them (accounting for compartments) self._node_elements = list() for node in nodes: compi = self.controller.get_compartment_of_node(self.net_index, node.index) layers = Canvas.NODE_LAYER if compi == -1 else (Canvas.COMPARTMENT_LAYER, compi, 1) self._node_elements.append(self.CreateNodeElement(node, layers)) # create reaction elements and assign the correct layers self._reaction_elements = list() for rxn in reactions: related_nodes = set(chain(rxn.sources, rxn.targets)) top_layer = max( el.layers for el in self._node_elements if el.node.index in related_nodes) # Make sure reaction is displayed above its top-most node self._reaction_elements.append(self.CreateReactionElement(rxn, layer_above(top_layer))) # Initialize elements list select_elements = cast(List[CanvasElement], self._node_elements) + cast( List[CanvasElement], self._reaction_elements) + cast( List[CanvasElement], self._compartment_elements) for rxn_el in self._reaction_elements: # Add Bezier handle and center elements select_elements += rxn_el.bhandles select_elements.append(rxn_el.center_el) # Update reactions on whether they are selected rxn_el.selected = rxn_el.reaction.index in new_sel_reactions for plugin_el in self._plugin_elements: select_elements.append(plugin_el) self._model_elements = SortedKeyList(select_elements, lambda e: e.layers) self._select_box.update(self.GetSelectedNodes(), [c for c in self._compartments if self.sel_compartments_idx.contains(c.index)]) self._UpdateSelectBoxLayer() self._select_box.related_elts = select_elements self._widget_elements = SortedKeyList([self._select_box], lambda e: e.layers) self._minimap.elements = self._model_elements self._UpdateSelectedLists() self.FullRedraw() post_event(CanvasDidUpdateEvent()) def GetCompartment(self, comp_idx: int) -> Optional[Compartment]: for comp in self._compartments: if comp.index == comp_idx: return comp return Optional[None] def _SetStatusText(self, name: str, text: str): idx = self._reverse_status[name] self._status_bar.SetStatusText(text, idx) def SetOriginPos(self, pos: Vec2): """Set the origin position (position of the topleft corner) to pos by scrolling.""" # check if out of bounds pos = pos.map(lambda e: max(e, 0)) limit = self.realsize * cstate.scale - Vec2(self.GetSize()) pos = pos.reduce2(min, limit) pos = pos.elem_div(Vec2(self.GetScrollPixelsPerUnit())) # need to mult by scale here since self.VirtualPosition is artificially increased, per # scale * self.realsize #self.Scroll(*pos) pos_list = list(pos) pos_list = [int(pos_item) for pos_item in pos_list] self.Scroll(*pos_list) self.SetOverlayPositions() @property def zoom_level(self) -> int: return self._zoom_level def SetZoomLevel(self, zoom: int, anchor: Vec2): """Zoom in/out with the given anchor. The anchor point stays at the same relative position after zooming. Note that the anchor position is scrolled position, i.e. device position """ if zoom < Canvas.MIN_ZOOM_LEVEL or zoom > Canvas.MAX_ZOOM_LEVEL: raise ValueError('Zoom level must be between {} and {}. Got {} instead.', Canvas.MIN_ZOOM_LEVEL, Canvas.MAX_ZOOM_LEVEL, zoom) self._zoom_level = zoom old_scale = cstate.scale cstate.scale = 1.2 ** zoom # adjust scroll position logical = Vec2(self.CalcUnscrolledPosition(anchor.to_wx_point())) scaled = logical * (cstate.scale / old_scale) newanchor = Vec2(self.CalcScrolledPosition(scaled.to_wx_point())) # the amount of shift needed to keep anchor at the same position shift = newanchor - anchor cur_scroll = Vec2(self.CalcUnscrolledPosition(0, 0)) new_scroll = cur_scroll + shift # convert to scroll units new_scroll = new_scroll.elem_div(Vec2(self.GetScrollPixelsPerUnit())) vsize = self.realsize * cstate.scale self.SetVirtualSize(int(vsize.x), int(vsize.y)) # Important: set virtual size first, then scroll self.Scroll(int(new_scroll.x), int(new_scroll.y)) self.zoom_slider.SetValue(self._zoom_level) self.zoom_slider.SetPageSize(2) self._SetStatusText('zoom', '{:.2f}x'.format(cstate.scale)) self.FullRedraw() def ZoomCenter(self, zooming_in: bool): """Zoom in on the center of the visible window.""" self.IncrementZoom(zooming_in, Vec2(self.GetSize()) / 2) def IncrementZoom(self, zooming_in: bool, anchor: Vec2): """Zoom in/out by one step on the anchor, if within zoom range.""" new_zoom = self._zoom_level + (1 if zooming_in else -1) if new_zoom < self.MIN_ZOOM_LEVEL or new_zoom > self.MAX_ZOOM_LEVEL: return self.SetZoomLevel(new_zoom, anchor) def ResetZoom(self): """Reset the zoom level, with the anchor on the center of the visible window.""" self.SetZoomLevel(0, Vec2(self.GetSize()) / 2) def FitNodeSizeToText(self): dc = wx.WindowDC(self) gc = wx.GraphicsContext.Create(dc) min_w = get_theme('node_width') min_h = get_theme('node_height') with self.controller.group_action(): for node in self.nodes: # TODO set font tp = node.composite_shape.text_item[0] font = wx.Font(wx.FontInfo(tp.font_size) .Family(tp.font_family) .Style(tp.font_style) .Weight(tp.font_weight)) gfont = gc.CreateFont(font) gc.SetFont(gfont) w, h, _, _ = gc.GetFullTextExtent(node.id) w += 20 h += 10 w = max(w, min_w) h = max(h, min_h) self.controller.set_node_size(self.net_index, node.index, Vec2(w, h)) def _GetUniqueName(self, base: str, names: Collection[str], *args: Collection[str]) -> str: """Given a base name "x", try "x_0", "x_1", ... until it is unique in all the collections. """ increment = 0 # keep incrementing as long as there is duplicate ID while True: suffix = '_{}'.format(increment) cur_id = base + suffix if cur_id in names: increment += 1 continue for arg in args: if cur_id in arg: increment += 1 break else: # loop finished normally; done return cur_id def OnLeftDown(self, evt): try: device_pos = Vec2(evt.GetPosition()) logical_pos = Vec2(self.CalcUnscrolledPosition(evt.GetPosition())) / cstate.scale # Check if clicked on overlay using device_pos overlay = self._InWhichOverlay(device_pos) if overlay is not None: overlay.hovering = True overlay.OnLeftDown(device_pos) return for ol in self._overlays: if ol is not overlay and ol.hovering: ol.hovering = False if cstate.input_mode == InputMode.SELECT: for el in self._ElementsHighToLow(): if not el.enabled: continue if el.pos_inside(logical_pos) and el.on_left_down(logical_pos): self.dragged_element = el self._last_drag_pos = logical_pos break else: self.dragged_element = None # variables for keeping track if clicked on a selectable element node: Optional[Node] = None rxn: Optional[Reaction] = None comp: Optional[Compartment] = None rxn_center: Optional[ReactionCenter] = None if isinstance(self.dragged_element, NodeElement): n_elem = typing.cast(NodeElement, self.dragged_element) node = n_elem.node elif isinstance(self.dragged_element, ReactionElement): r_elem = typing.cast(ReactionElement, self.dragged_element) rxn = r_elem.reaction elif isinstance(self.dragged_element, CompartmentElt): c_elem = typing.cast(CompartmentElt, self.dragged_element) comp = c_elem.compartment elif isinstance(self.dragged_element, ReactionCenter): rxn_center = typing.cast(ReactionCenter, self.dragged_element) rxn = rxn_center.parent.reaction # not resizing or dragging if cstate.multi_select: if rxn is not None: if self.sel_reactions_idx.contains(rxn.index): self.sel_reactions_idx.remove(rxn.index) else: self.sel_reactions_idx.add(rxn.index) elif node is not None: if self.sel_nodes_idx.contains(node.index): self.sel_nodes_idx.remove(node.index) else: self.sel_nodes_idx.add(node.index) elif comp is not None: if self.sel_compartments_idx.contains(comp.index): self.sel_compartments_idx.remove(comp.index) else: self.sel_compartments_idx.add(comp.index) else: with self._SelectGroupEvent(): if rxn is not None: self.sel_reactions_idx.set_item({rxn.index}) self.sel_nodes_idx.set_item(set()) self.sel_compartments_idx.set_item(set()) elif node is not None: self.sel_nodes_idx.set_item({node.index}) self.sel_reactions_idx.set_item(set()) self.sel_compartments_idx.set_item(set()) elif comp is not None: self.sel_nodes_idx.set_item(set()) self.sel_reactions_idx.set_item(set()) self.sel_compartments_idx.set_item({comp.index}) elif self.dragged_element is None: # clear selected nodes self.sel_nodes_idx.set_item(set()) self.sel_reactions_idx.set_item(set()) self.sel_compartments_idx.set_item(set()) if not cstate.multi_select: # if clicked on a new node/compartment, immediately allow dragging on the # updated select box if (node or comp) and self._select_box.pos_inside(logical_pos): self._select_box.on_mouse_enter(logical_pos) good = self._select_box.on_left_down(logical_pos) assert good self.dragged_element = self._select_box self.hovered_element = self._select_box self._FloatNodes() return # clicked on nothing; drag-selecting if self.dragged_element is None: self._drag_selecting = True self._drag_select_start = logical_pos self._drag_rect = Rect(self._drag_select_start, Vec2()) self.drag_sel_nodes_idx = set() self.drag_sel_rxns_idx = set() self.drag_sel_comps_idx = set() elif isinstance(self.dragged_element, SelectBox): self._FloatNodes() elif cstate.input_mode == InputMode.ADD_NODES: size = Vec2(get_theme('node_width'), get_theme('node_height')) unscaled_pos = logical_pos adj_pos = unscaled_pos - size / 2 node = Node( 'x', self.net_index, pos=adj_pos, size=size, # fill_color=get_theme('node_fill'), # border_color=get_theme('node_border'), # border_width=get_theme('node_border_width'), comp_idx=self.RectInWhichCompartment(Rect(adj_pos, size)), floatingNode=True, lockNode=False, ) node.position = clamp_rect_pos(node.rect, Rect(Vec2(), self.realsize), BOUNDS_EPS) node.id = self._GetUniqueName(node.id, [n.id for n in self._nodes]) with self.controller.group_action(): nodei = self.controller.add_node_g(self._net_index, node) fill_color = get_theme('node_fill') border_color = get_theme('node_border') border_width = get_theme('node_border_width') self.controller.set_node_fill_rgb(self._net_index, nodei, fill_color) self.controller.set_node_fill_alpha(self._net_index, nodei, fill_color.Alpha()) self.controller.set_node_border_rgb(self._net_index, nodei, border_color) self.controller.set_node_border_alpha( self._net_index, nodei, border_color.Alpha()) self.controller.set_node_border_width(self._net_index, nodei, border_width) index = self.controller.get_node_index(self._net_index, node.id) with self._SelectGroupEvent(): self.sel_nodes_idx.set_item({index}) self.sel_reactions_idx.set_item(set()) self.sel_compartments_idx.set_item(set()) elif cstate.input_mode == InputMode.ADD_COMPARTMENTS: self._drag_selecting = True self._drag_select_start = logical_pos self._drag_rect = Rect(self._drag_select_start, Vec2()) self.drag_sel_nodes_idx = set() elif cstate.input_mode == InputMode.ZOOM: zooming_in = not wx.GetKeyState(wx.WXK_SHIFT) self.IncrementZoom(zooming_in, Vec2(device_pos)) finally: if self.dragged_element is not None: self._RedrawDynamicToBuffer() else: self.FullRedraw() evt.Skip() wx.CallAfter(self.SetFocus) def _FloatNodes(self): """Helper that temporarily resets the layer of the nodes being dragged. """ if self._nodes_floating: return self._nodes_floating = True # Only "float" if only nodes (and possibly reactions) are selected. if len(self.sel_compartments_idx) != 0: return node_elements: List[NodeElement] = list() for elt in self._node_elements: elt = cast(NodeElement, elt) if elt.node.index in self.sel_nodes_idx: node_elements.append(elt) for elt in node_elements: self.ResetLayer(elt, self.DRAGGED_NODE_LAYER) def _UnfloatNodes(self): # Only "float" if only nodes (and possibly reactions) are selected. if len(self.sel_compartments_idx) != 0: return for elt in self._node_elements: self.ResetLayer(elt, self.NODE_LAYER) def OnLeftUp(self, evt): try: self._EndDrag(evt) # self._UnfloatNodes() self._nodes_floating = False finally: self.FullRedraw() evt.Skip() def OnRightUp(self, evt): """Right mouse button up event handler. Note that the handling of the right click up event is determined by the canvas rather than by the individual elements. """ device_pos = Vec2(evt.GetPosition()) logical_pos = Vec2(self.CalcUnscrolledPosition(evt.GetPosition())) / cstate.scale overlay = self._InWhichOverlay(device_pos) if overlay is not None: return node_el: Optional[NodeElement] = None reaction_el: Optional[ReactionElement] = None comp_el: Optional[CompartmentElt] = None for el in self._ElementsHighToLow(): if not el.enabled: continue if el.pos_inside(logical_pos): if isinstance(el, NodeElement): node_el = cast(NodeElement, el) break elif isinstance(el, ReactionElement): reaction_el = cast(ReactionElement, el) break elif isinstance(el, CompartmentElt): comp_el = cast(CompartmentElt, el) break on_selected = False # Whether clicked on a selected element. selected_nodes: Set[int] = set() selected_reactions: Set[int] = set() selected_comps: Set[int] = set() if node_el is not None: if node_el.node.index in self.sel_nodes_idx: on_selected = True else: selected_nodes.add(node_el.node.index) elif reaction_el is not None: if reaction_el.reaction.index in self.sel_reactions_idx: on_selected = True else: selected_reactions.add(reaction_el.reaction.index) elif comp_el is not None: if comp_el.compartment.index in self.sel_compartments_idx: on_selected = True else: selected_comps.add(comp_el.compartment.index) if on_selected: # If right-clicked on selected element, then don't need to update anything, but only # need to populate right-clicked element lists. selected_nodes = self.sel_nodes_idx.item_copy() selected_reactions = self.sel_reactions_idx.item_copy() selected_comps = self.sel_compartments_idx.item_copy() else: # If right-clicked on something not selected, update selected indices. cur_selected = (len(self.sel_nodes_idx) + len(self.sel_reactions_idx) + len(self.sel_compartments_idx)) new_selected = len(selected_nodes) + len(selected_reactions) + len(selected_comps) # If nothing is selected before or after, don't update anything if cur_selected != 0 or new_selected != 0: with self._SelectGroupEvent(): self.sel_nodes_idx.set_item(selected_nodes) self.sel_reactions_idx.set_item(selected_reactions) self.sel_compartments_idx.set_item(selected_comps) # Whether clicked on something? total_selected = len(selected_nodes) + len(selected_reactions) + len(selected_comps) menu = wx.Menu() # def add_item(menu: wx.Menu, menu_name, callback): # qmi = wx.MenuItem(menu, -1, menu_name) # #qmi.SetBitmap(wx.Bitmap('exit.png')) # id_ = menu.Append(qmi) # (-1, menu_name).Id # menu.Bind(wx.EVT_MENU, lambda _: callback(), id=id_) def add_item(menu: wx.Menu, menu_name: str, callback, image_name: str = None): item = menu.Append(-1, menu_name) if image_name != None: item.SetBitmap(wx.Bitmap(resource_path(image_name))) menu.Bind(wx.EVT_MENU, lambda _: callback(), id=item.Id) if total_selected != 0: add_item(menu, 'Delete', self.DeleteSelectedItems) if len(selected_nodes) != 0: menu.AppendSeparator() add_item(menu, 'Create Alias', lambda: self.CreateAliases(self.sel_nodes)) if len(self.sel_nodes) == 1 and len(self.node_to_rxn[self.sel_nodes[0].index]) > 1: add_item(menu, 'Split on Reactions', lambda: self.SplitAliasesOnReactions(self.sel_nodes[0])) # Only allow align when the none of the nodes are in a compartment. This prevents # nodes inside a compartment being arranged outside. if not any(node.comp_idx != -1 for node in self.sel_nodes): # Only allow alignment if all selected nodes are in the same compartment # Add alignment options menu.AppendSeparator() align_menu = wx.Menu() add_item(align_menu, 'Align Left', lambda: self.AlignSelectedNodes(Alignment.LEFT), 'alignLeft_XP.png') add_item(align_menu, 'Align Right', lambda: self.AlignSelectedNodes(Alignment.RIGHT), 'alignRight_XP.png') add_item(align_menu, 'Align Center', lambda: self.AlignSelectedNodes(Alignment.CENTER), 'alignHorizCenter_XP.png') align_menu.AppendSeparator() add_item(align_menu, 'Align Top', lambda: self.AlignSelectedNodes(Alignment.TOP), 'alignTop_XP.png') add_item(align_menu, 'Align Bottom', lambda: self.AlignSelectedNodes(Alignment.BOTTOM), 'AlignBottom_XP.png') add_item(align_menu, 'Align Middle', lambda: self.AlignSelectedNodes(Alignment.MIDDLE), 'alignVertCenter_XP.png') align_menu.AppendSeparator() add_item(align_menu, 'Grid', lambda: self.AlignSelectedNodes(Alignment.GRID), 'alignOnGrid_XP.png') align_menu.AppendSeparator() add_item(align_menu, 'Arrange Horizontally', lambda: self.AlignSelectedNodes(Alignment.HORIZONTAL), 'alignHorizEqually_XP.png') add_item(align_menu, 'Arrange Vertically', lambda: self.AlignSelectedNodes(Alignment.VERTICAL), 'alignVertEqually_XP.png') menu.AppendSubMenu(align_menu, text='Align...') # Must refresh before the context menu is displayed, otherwise the refresh won't occur self.Refresh() self.PopupMenu(menu) menu.Destroy() def CreateAliases(self, nodes: List[Node]): new_indices = set() with self.controller.group_action(): for node in nodes: alias_pos = node.position + Vec2.repeat(20) if node.comp_idx >= 0: comp = self.comp_idx_map[node.comp_idx] alias_pos = clamp_rect_pos(Rect(alias_pos, node.size), comp.rect) new_idx = self.controller.add_alias_node(self.net_index, node.index, alias_pos, node.size) new_indices.add(new_idx) self.sel_nodes_idx.set_item(new_indices) def SplitAliasesOnReactions(self, node: Node): rea_els = [re for re in self._reaction_elements if re.reaction.index in self.node_to_rxn[node.index]] with self.controller.group_action(): # exclude the first reaction for rea_el in rea_els[1:]: reaction = rea_el.reaction alias_pos = node.position * 0.8 + rea_el.bezier.real_center * 0.2 if node.comp_idx >= 0: comp = self.comp_idx_map[node.comp_idx] alias_pos = clamp_rect_pos(Rect(alias_pos, node.size), comp.rect) # move node position slightly toward the position of the reaction self.controller.alias_for_reaction(self.net_index, reaction.index, node.index, alias_pos, node.size) def GetBoundingRect(self) -> Optional[Rect]: """Get the bounding rectangle of all nodes, reactions, and compartments. Returns None if there are no nodes, reactions, or compartments. """ rects = list() for el in self._node_elements: rects.append(el.bounding_rect()) for el in self._reaction_elements: rects.append(el.bounding_rect()) for el in self._compartment_elements: rects.append(el.bounding_rect()) if len(rects) != 0: return get_bounding_rect(rects) else: return None def findMinX(self, nodes: List[Node]): ''' Find the left-most node's x position Args: self l: the list of indices of the selected nodes ''' return min(n.position.x for n in nodes) def findMaxX(self, nodes: List[Node]): ''' Find the right-most node's x position Args: self l: the list of indices of the selected nodes ''' return max(n.position.x for n in nodes) def findMinY(self, nodes: List[Node]): ''' Find the left-most node's x position Args: self l: the list of indices of the selected nodes ''' return min(n.position.y for n in nodes) def findMaxY(self, nodes: List[Node]): ''' Find the right-most node's x position Args: self l: the list of indices of the selected nodes ''' return max(n.position.y for n in nodes) def _DefaultHandlePositions(self, rea_el: ReactionElement): center = rea_el.bezier.real_center reactants = [self.node_idx_map[i] for i in rea_el.reaction.sources] products = [self.node_idx_map[i] for i in rea_el.reaction.targets] return default_handle_positions(center, reactants, products) def setDefaultHandles(self): with self.controller.group_action(): for r_el in self._reaction_elements: r = r_el.reaction handles = self._DefaultHandlePositions(r_el) # centroid, sources, target sources = r.sources targets = r.targets self.controller.set_center_handle(0, r.index, handles[0]) count = 1 for s in sources: self.controller.set_src_node_handle(0, r.index, s, handles[count]) count += 1 for t in targets: self.controller.set_dest_node_handle(0, r.index, t, handles[count]) count += 1 def AlignSelectedNodes(self, alignment: Alignment): """Align the selected nodes. Should be called only when *only* nodes are selected.""" # The selected nodes are self.sel_nodes # To access a file in the resources folder, use # Jin_edit:refer to plugins/arrange.py sel_nodes = self.sel_nodes if alignment == Alignment.LEFT: # Align selected nodes to the left-most node's x position with self.controller.group_action(): xpos = self.findMinX(sel_nodes) for n in sel_nodes: newPos = Vec2(xpos, n.position.y) self.controller.move_node(self.net_index, n.index, newPos) self.setDefaultHandles() elif alignment == Alignment.RIGHT: ''' Align selected nodes to the right-most node's x position ''' with self.controller.group_action(): xpos = self.findMaxX(sel_nodes) for n in self.sel_nodes: newPos = Vec2(xpos, n.position.y) self.controller.move_node(self.net_index, n.index, newPos) self.setDefaultHandles() elif alignment == Alignment.CENTER: ''' Align selected nodes to the relative center of the x positions of the nodes ''' with self.controller.group_action(): xMin = self.findMinX(sel_nodes) xMax = self.findMaxX(sel_nodes) xpos = math.floor((xMax + xMin)/2) for n in sel_nodes: newPos = Vec2(xpos, n.position.y) self.controller.move_node(self.net_index, n.index, newPos) self.setDefaultHandles() elif alignment == Alignment.TOP: with self.controller.group_action(): ypos = self.findMinY(sel_nodes) for n in self.sel_nodes: newPos = Vec2(n.position.x, ypos) self.controller.move_node(self.net_index, n.index, newPos) self.setDefaultHandles() elif alignment == Alignment.BOTTOM: with self.controller.group_action(): ypos = self.findMaxY(sel_nodes) for n in self.sel_nodes: newPos = Vec2(n.position.x, ypos) self.controller.move_node(self.net_index, n.index, newPos) self.setDefaultHandles() elif alignment == Alignment.MIDDLE: # Align selected nodes to the relative center of the x positions of the nodes with self.controller.group_action(): yMin = self.findMinY(sel_nodes) yMax = self.findMaxY(sel_nodes) ypos = math.floor((yMax + yMin)/2) for n in sel_nodes: newPos = Vec2(n.position.x, ypos) self.controller.move_node(0, n.index, newPos) self.setDefaultHandles() elif alignment == Alignment.GRID: ''' Align selected nodes in a net grid manner ''' with self.controller.group_action(): x = 40 y = 40 count = 1 for n in sel_nodes: self.controller.move_node(0, n.index, Vec2(x, y)) x = x + 130 if count % 5 == 0: y = y + 130 x = 40 count = count + 1 self.setDefaultHandles() elif alignment == Alignment.HORIZONTAL: # Sort the selected nodes in x position ascending order nodes = sorted(sel_nodes, key=lambda x: x.position.x) # find the average distance beteeen the selected nodes averageDistance = 0.0 for count in range(1, len(nodes)): node2 = nodes[count-1] node1 = nodes[count] averageDistance += (node1.position.x + node1.size.x) - node2.position.x averageDistance = averageDistance / len(nodes) with self.controller.group_action(): # x = Position of left most node x = nodes[0].position.x # Arrange nodes with equal distance between them for count in range(len(nodes)): newPos = Vec2(x, nodes[count].position.y) self.controller.move_node(0, nodes[count].index, newPos) x = x + averageDistance self.setDefaultHandles() elif alignment == Alignment.VERTICAL: # Sort the selected nodes in y position ascending order nodes = sorted(sel_nodes, key=lambda x: x.position.y) # find the average distance beteeen the selected nodes averageDistance = 0.0 for count in range(1, len(nodes)): node2 = nodes[count - 1] node1 = nodes[count] averageDistance += (node1.position.y + node1.size.y) - node2.position.y averageDistance = averageDistance / len(nodes) with self.controller.group_action(): # y = Position of top most node y = nodes[0].position.y # Arrange nodes with equal distance between them for count in range(len(nodes)): newPos = Vec2(nodes[count].position.x, y) self.controller.move_node(0, nodes[count].index, newPos) y = y + averageDistance self.setDefaultHandles() # TODO improve this. we might want a special mouseLeftWindow event def _EndDrag(self, evt: wx.MouseEvent): """Send the updated node positions and sizes to the controller. This is called after a dragging operation has completed in OnLeftUp or OnLeaveWindow. """ device_pos = Vec2(evt.GetPosition()) overlay = self._InWhichOverlay(device_pos) if self._minimap.dragging: self._minimap.OnLeftUp(device_pos) # HACK once we integrate overlays (e.g. minimap) as CanvasElements, we can simply call # on_mouse_leave or something self._minimap.hovering = False elif self._minimap.hovering: self._minimap.hovering = False elif self._drag_selecting: self._drag_selecting = False if cstate.input_mode == InputMode.SELECT: self.sel_nodes_idx.union(self.drag_sel_nodes_idx) self.sel_reactions_idx.union(self.drag_sel_rxns_idx) self.sel_compartments_idx.union(self.drag_sel_comps_idx) self.drag_sel_nodes_idx = set() self.drag_sel_rxns_idx = set() self.drag_sel_comps_idx = set() elif cstate.input_mode == InputMode.ADD_COMPARTMENTS: id = self._GetUniqueName('c', [c.id for c in self._compartments]) size = self._drag_rect.size # make sure the compartment is at least of some size adj_size = Vec2(max(size.x, get_setting('min_comp_width')), max(size.y, get_setting('min_comp_height'))) # compute position size_diff = adj_size - self._drag_rect.size # center position if drag_rect size has been adjusted pos = self._drag_rect.position - size_diff / 2 comp = Compartment(id, index=self.comp_index, net_index=self.net_index, nodes=list(), volume=1, position=pos, size=adj_size, fill=get_theme('comp_fill'), border=get_theme('comp_border'), border_width=get_theme('comp_border_width'), ) # clip position comp.position = clamp_rect_pos(comp.rect, Rect(Vec2(), self.realsize), BOUNDS_EPS) self.controller.add_compartment_g(self.net_index, comp) elif cstate.input_mode == InputMode.SELECT: # perform left_up on dragged_element if it exists, or just find the node under the # cursor logical_pos = self.CalcScrolledPositionFloat(device_pos) / cstate.scale if self.dragged_element is not None: self.dragged_element.on_left_up(logical_pos) self.dragged_element = None elif self.hovered_element is not None: self.hovered_element.on_mouse_leave(logical_pos) self.hovered_element = None elif evt.LeftIsDown(): for el in self._ElementsHighToLow(): if not el.enabled: continue if el.pos_inside(logical_pos) and el.on_left_up(logical_pos): return if overlay is not None: overlay.OnLeftUp(device_pos) def CalcScrolledPositionFloat(self, pos: Vec2) -> Vec2: """Convert logical position to scrolled (device) position, retaining floating point. self.CalcScrolledPosition() converts the input floats to ints. This is needed if better accuracy is needed. """ return Vec2(self.CalcScrolledPosition(wx.Point(0, 0))) + pos def CalcUnscrolledPositionFloat(self, pos: Vec2) -> Vec2: return Vec2(self.CalcUnscrolledPosition(wx.Point(0, 0))) + pos def OnMotion(self, evt): now = time.time() if now - self.last_motion < 0.01: return self.last_motion = now # Update cursor status text here if self._cursor_logical_pos is not None: rounded = self._cursor_logical_pos.map(lambda e: round(e, 2)) status_text = repr(rounded) self._SetStatusText('cursor', status_text) assert isinstance(evt, wx.MouseEvent) redraw = False try: device_pos = Vec2(evt.GetPosition()) logical_pos = Vec2(self.CalcUnscrolledPosition(evt.GetPosition())) / cstate.scale self._cursor_logical_pos = logical_pos rxn_radius = get_theme('reaction_radius') if self._drag_selecting: # assert evt.leftIsDown topleft = Vec2(min(logical_pos.x, self._drag_select_start.x), min(logical_pos.y, self._drag_select_start.y)) botright = Vec2(max(logical_pos.x, self._drag_select_start.x), max(logical_pos.y, self._drag_select_start.y)) self._drag_rect = Rect(topleft, botright - topleft) if cstate.input_mode == InputMode.SELECT: selected_nodes = [n for n in self._nodes if rects_overlap(n.s_rect, self._drag_rect)] selected_comps = [c for c in self._compartments if rects_overlap(c.rect, self._drag_rect)] selected_rxns = [re.reaction for re in self._reaction_elements if rects_overlap(circle_bounds( re.bezier.real_center, rxn_radius), self._drag_rect)] new_drag_sel_nodes_idx = set(n.index for n in selected_nodes) new_drag_sel_rxns_idx = set(r.index for r in selected_rxns) new_drag_sel_comps_idx = set(c.index for c in selected_comps) reactions_updated = new_drag_sel_rxns_idx != self.drag_sel_rxns_idx if (new_drag_sel_nodes_idx != self.drag_sel_nodes_idx or reactions_updated or new_drag_sel_comps_idx != self.drag_sel_comps_idx): self.drag_sel_nodes_idx = new_drag_sel_nodes_idx self.drag_sel_rxns_idx = new_drag_sel_rxns_idx self.drag_sel_comps_idx = new_drag_sel_comps_idx self._UpdateSelectedLists() if reactions_updated: # If drag selected reactions changed, then update reaction selection state. all_selected = self.drag_sel_rxns_idx | self.sel_reactions_idx.item_copy() for rel in self._reaction_elements: rel.selected = rel.reaction.index in all_selected elif cstate.input_mode == InputMode.ADD_COMPARTMENTS: pass redraw = True return # dragging takes priority here if evt.leftIsDown: # dragging if self.dragged_element is not None: rel_pos = logical_pos - self._last_drag_pos if self.dragged_element.on_mouse_drag(logical_pos, rel_pos): redraw = True self._last_drag_pos = logical_pos # If redrawing, i.e. dragged element moved, return immediately. Otherwise # we need to check if the mouse is still inside the dragged element. # The early return is for performance. if redraw: return elif self._minimap.dragging: self._minimap.OnMotion(device_pos, evt.LeftIsDown()) redraw = True return else: overlay = self._InWhichOverlay(device_pos) if overlay is not None: overlay.OnMotion(device_pos, evt.LeftIsDown()) overlay.hovering = True redraw = True # un-hover all other overlays TODO keep track of the currently hovering overlay for ol in self._overlays: if ol is not overlay and ol.hovering: ol.hovering = False redraw = True # Likely hovering on something else hovered: Optional[CanvasElement] = None for el in self._ElementsHighToLow(): if not el.enabled: continue if el.pos_inside(logical_pos): hovered = el break if cstate.input_mode == InputMode.ADD_NODES and ( isinstance(self.hovered_element, CompartmentElt) or isinstance(hovered, CompartmentElt)): # set redraw to True whenever input mode is ADD_NODES and mouse enters, exits, # or moves in a compartment. This is for updating the hightlight of the compartment. # (see CompartmentElt.on_paint). redraw = True if hovered is not None and hovered is self.hovered_element: moved = self.hovered_element.on_mouse_move(logical_pos) redraw = redraw or moved else: if self.hovered_element is not None: redraw = redraw or self.hovered_element.on_mouse_leave(logical_pos) if hovered is not None: redraw = redraw or hovered.on_mouse_enter(logical_pos) self.hovered_element = hovered finally: if redraw: self.LazyRefresh() evt.Skip() def FullRedraw(self): '''Function to signal that the entire canvas needs to be redrawn.''' self._static_bitmap = None self._dirty = True self.LazyRefresh() def LazyRefresh(self) -> bool: now = int(time.time() * 1000) diff = now - self._last_refresh if diff < self.MILLIS_PER_REFRESH: def callback(then_time): if then_time == self._last_refresh: # no refreshes since then; do the refresh now self._last_refresh = then_time self.Refresh() wx.CallLater(self.MILLIS_PER_REFRESH, lambda: callback(now)) return False else: self._last_refresh = now self.Refresh() return True def RedrawModelElements(self): '''Redraw the 'model', i.e. non-widget, elements, such as nodes and reactions. This is used for optimizing drawing. ''' pass def OnPaint(self, evt): self._accum_frames += 1 now = time.time() * 1000 diff = now - self._last_fps_update if diff >= 1000: self._last_fps_update = int(now) fps = int(self._accum_frames / diff * 1000) self._SetStatusText('fps', 'refreshes/sec: {}'.format(int(fps))) self._accum_frames = 0 self.SetOverlayPositions() # have to do this here to prevent jitters dc = wx.PaintDC(self) # transform for drawing to scrolled coordinates self.DoPrepareDC(dc) # Draw everything gc = wx.GraphicsContext.Create(dc) assert gc is not None if self._static_bitmap is None: # draw the whole thing self._RedrawDynamicToBuffer() wpos = Vec2(self.CalcUnscrolledPosition(0, 0)) wsize = Vec2(self.GetSize()) draw_rect( gc, Rect(wpos, wsize), fill=get_theme('canvas_outside_bg'), ) subpos = wpos # sometimes the subbitmap might overflow. need to restrict its size to be within the canvas subsize = Vec2(min(self.realsize.x - subpos.x, wsize.x), min(self.realsize.y - subpos.y, wsize.y)) bitmap = self._static_bitmap.GetSubBitmap(wx.Rect(int(subpos.x), int(subpos.y), int(subsize.x), int(subsize.y))) gc.DrawBitmap(bitmap, wpos.x, wpos.y, bitmap.GetWidth(), bitmap.GetHeight()) # draw dynamic elements gc.PushState() gc.Scale(cstate.scale, cstate.scale) for elt in self._ElementsLowToHigh(): if elt in self._dynamic_elements and elt.enabled: # draw to static buffer elt.on_paint(gc) self.DrawVisualCuesToGC(gc) gc.PopState() # Draw minimap self._minimap.DoPaint(gc) post_event(DidPaintCanvasEvent(gc)) def DrawActiveRectToImage(self): """Draw to image only the active rectangle -- bounding rect of nodes, reactions, & compartments """ bounding_rect = self.GetBoundingRect() if bounding_rect is None: return None bounding_rect = padded_rect(bounding_rect, padding=50) #bounding_rect = wx.Rect(*bounding_rect.position, *bounding_rect.size) pos_list = list(bounding_rect.position) pos_list = [int(pos_item) for pos_item in pos_list] size_list = list(bounding_rect.size) size_list = [int(size_item) for size_item in size_list] bounding_rect = wx.Rect(*pos_list, *size_list) bounding_rect.Intersect(wx.Rect(0, 0, *self.realsize)) bmp = wx.Bitmap(*self.realsize) dc = wx.MemoryDC() dc.SelectObject(bmp) gc = wx.GraphicsContext.Create(dc) self.DrawBackgroundToGC(gc) self.DrawModelToGC(gc) img = bmp.ConvertToImage() ret = img.GetSubImage(bounding_rect) return ret def DrawBackgroundToGC(self, gc): # # Draw gray background # draw_rect( # gc, # Rect(Vec2(0, 0), self.realsize + Vec2(10, 10)), # fill=get_theme('canvas_outside_bg'), # ) # Draw background TODO move this before gc.Scale() draw_rect( gc, Rect(Vec2(0, 0), self.realsize), fill=get_theme('canvas_bg'), ) def DrawModelToGC(self, gc: wx.GraphicsContext): for elt in self._model_elements: if elt.enabled: elt.on_paint(gc) def _GetReactionWidgets(self, rxn_elt: ReactionElement) -> Iterable[CanvasElement]: return chain(rxn_elt.bhandles, [rxn_elt.center_el]) def _GetDynamicElements(self) -> Set[CanvasElement]: '''Get the set of elements that will change, as self.dragged_element is dragged. ''' if self.dragged_element is None: return set() elts = list() if isinstance(self.dragged_element, SelectBox): elts.append(self.dragged_element) # all the nodes that move along with the select box node_idc = set(chain([n.index for n in self.dragged_element.nodes], [n.index for n in self.dragged_element.peripheral_nodes])) rxn_idc = set().union(*(self.node_to_rxn[ni] for ni in node_idc)) comp_idc = set(c.index for c in self.dragged_element.compartments) elts += [cast(CanvasElement, x) for x in self._node_elements if x.node.index in node_idc] elts += [cast(CanvasElement, x) for x in self._compartment_elements if x.compartment.index in comp_idc] # add reactions rxn_elts = list(x for x in self._reaction_elements if x.reaction.index in rxn_idc) for rxn_elt in rxn_elts: elts.append(rxn_elt) elts += self._GetReactionWidgets(rxn_elt) elif isinstance(self.dragged_element, ReactionCenter): rxn_elt = self.dragged_element.parent elts.append(rxn_elt) elts += self._GetReactionWidgets(rxn_elt) elif isinstance(self.dragged_element, BezierHandle): ri = self.dragged_element.reaction.index rxn_elts = [x for x in self._reaction_elements if x.reaction.index == ri] assert(len(rxn_elts) == 1) rxn_elt = rxn_elts[0] elts.append(rxn_elt) elts += self._GetReactionWidgets(rxn_elt) # TODO add # if isinstance(self.dragged_element, ReactionElement): # rxn_elt = self.dragged_element # elif isinstance(self.dragged_element, ReactionCenter): # rxn_elt = self.dragged_element.parent # elif isinstance(self.dragged_element, BezierHandle): # # TODO add twin # rxn_elt = self.dragged_element.reaction # if rxn_elt is not None: # elts.append(rxn_elt) # elts += rxn_elt.bhandles # elts.append(rxn_elt.center_el) return set(elts) def _RedrawDynamicToBuffer(self): self._dynamic_elements = self._GetDynamicElements() # No dynamic elements; simply redraw everything by not populating _static_bitmap temp_bitmap = wx.Bitmap(*self.realsize) self._dirty = False dc = wx.MemoryDC() dc.SelectObject(temp_bitmap) gc: wx.GraphicsContext = wx.GraphicsContext.Create(dc) self.DrawBackgroundToGC(gc) gc.PushState() gc.Scale(cstate.scale, cstate.scale) for elt in self._ElementsLowToHigh(): if elt not in self._dynamic_elements and elt.enabled: # draw to static buffer elt.on_paint(gc) gc.PopState() self._static_bitmap = temp_bitmap def _DrawCompartmentHighlight(self, gc: wx.GraphicsContext): # TODO this is not model within_comp = None if cstate.input_mode == InputMode.ADD_NODES and self._cursor_logical_pos is not None: size = Vec2(get_theme('node_width'), get_theme('node_height')) pos = self._cursor_logical_pos - size/2 within_comp = self.RectInWhichCompartment(Rect(pos, size)) elif self._select_box.special_mode == SelectBox.SMode.NODES_IN_ONE and self.dragged_element is not None: within_comp = self.InWhichCompartment(self._select_box.nodes) if within_comp is None or within_comp == -1: return for elt in self._compartment_elements: if elt.compartment.index == within_comp: elt.highlight_paint(gc) return assert False, 'Should not reach here' def _DrawDragSelectionRect(self, gc): if self._drag_selecting: fill: wx.Colour border: Optional[wx.Colour] bwidth: int if cstate.input_mode == InputMode.SELECT: fill = get_theme('drag_fill') border = get_theme('drag_border') bwidth = get_theme('drag_border_width') corner_radius = 0 elif cstate.input_mode == InputMode.ADD_COMPARTMENTS: fill = opacity_mul(get_theme('comp_fill'), 0.3) border = opacity_mul(get_theme('comp_border'), 0.3) bwidth = get_theme('comp_border_width') corner_radius = get_theme('comp_corner_radius') else: assert False, "Should not be _drag_selecting in any other input mode." if bwidth == 0: border = None draw_rect( gc, self._drag_rect, fill=fill, border=border, border_width=bwidth, corner_radius=corner_radius, ) def DrawVisualCuesToGC(self, gc): '''Visual cues include reactant/product outlines, drag-selection rectangle, and compartment highlight.''' # TODO Put this in SelectionChanged for el in self._ElementsLowToHigh(): if not el.enabled: continue el.on_paint_cue(gc) self._DrawCompartmentHighlight(gc) sel_rects = ([n.rect for n in self.sel_nodes] + [c.rect for c in self.sel_comps]) # If we are not drag-selecting, don't draw selection outlines if there is only one rect # selected (for aesthetics); but do draw outlines if drawing_drag is True (as # documented in _UpdateSelectedLists()) if len(sel_rects) > 1 or self.drawing_drag: for rect in sel_rects: rect = rect.aligned() # Draw selection outlines rect = padded_rect(rect, get_theme('select_outline_padding')) # draw rect draw_rect(gc, rect, border=get_theme('handle_color'), border_width=get_theme('select_outline_width'), corner_radius=0) # Draw reactant and product marker outlines def draw_reaction_outline(node: Node, color: wx.Colour, padding: int): draw_rect( gc, padded_rect(node.s_rect.aligned(), padding), fill=None, border=color, border_width=max(even_round(get_theme('react_node_border_width')), 2), border_style=wx.PENSTYLE_LONG_DASH, ) reactants = get_nodes_by_idx(self._nodes, self._reactant_idx) for node in reactants: draw_reaction_outline(node, get_theme('reactant_border'), get_theme('react_node_padding')) products = get_nodes_by_idx(self._nodes, self._product_idx) for node in products: pad = get_theme('react_node_border_width') + \ 3 if node.index in self._reactant_idx else 0 draw_reaction_outline(node, get_theme('product_border'), pad + get_theme('react_node_padding')) # Draw drag-selection rect self._DrawDragSelectionRect(gc) def ResetLayer(self, elt: CanvasElement, layers: Layer): if elt in self._model_elements: self._model_elements.remove(elt) elt.set_layers(layers) self._model_elements.add(elt) elif elt in self._widget_elements: self._widget_elements.remove(elt) elt.set_layers(layers) self._widget_elements.add(elt) def GetSelectedNodes(self, copy=False) -> List[Node]: """Get the list of selected nodes using self.sel_nodes_idx.""" if copy: return [copy.copy(n) for n in self._nodes if self.sel_nodes_idx.contains(n.index)] else: return [n for n in self._nodes if self.sel_nodes_idx.contains(n.index)] def OnScroll(self, evt): # Need to use wx.CallAfter() to ensure the scroll event is finished before we update the # position of the dragged node evt.Skip() self.SetOverlayPositions() self.LazyRefresh() # self.FullRedraw() def OnMouseWheel(self, evt): rot = evt.GetWheelRotation() if wx.GetKeyState(wx.WXK_CONTROL): # zooming in or out self.IncrementZoom(rot > 0, Vec2(evt.GetPosition())) else: # dispatch a horizontal scroll event in this case if evt.GetWheelAxis() == wx.MOUSE_WHEEL_VERTICAL and \ wx.GetKeyState(wx.WXK_SHIFT): evt.SetWheelAxis( wx.MOUSE_WHEEL_HORIZONTAL) # need to invert rotation for more intuitive scrolling evt.SetWheelRotation(-rot) evt.Skip() def OnSlider(self, evt): level = self.zoom_slider.GetValue() self.SetZoomLevel(level, Vec2(self.GetSize()) / 2) def OnLeaveWindow(self, evt): try: self._EndDrag(evt) finally: evt.Skip() def OnDidCommitNodePositions(self, evt): """Update reaction Bezier handles after nodes are dragged.""" evt = cast(DidCommitDragEvent, evt) if isinstance(evt.source, SelectBox): for elt in self._reaction_elements: elt.commit_node_pos() def _ElementsHighToLow(self) -> Iterable[CanvasElement]: return reversed(self._model_elements + self._widget_elements) def _ElementsLowToHigh(self) -> Iterable[CanvasElement]: return chain(self._model_elements, self._widget_elements) @contextmanager def _SelectGroupEvent(self): """Context for selection event group. See docs for in_selection_group for details.""" self._in_selection_group = True yield self._in_selection_group = False if self._selection_dirty: self._SelectionChanged() self._selection_dirty = False def _UpdateSelectedLists(self): """Helper that updates the selected nodes, etc. using the selected indices. Should be called when there is an update to the indices. """ sel_node_idx = self.sel_nodes_idx.item_copy() sel_rxn_idx = self.sel_reactions_idx.item_copy() sel_comp_idx = self.sel_compartments_idx.item_copy() orig_count = len(sel_node_idx) + len(sel_comp_idx) + len(sel_rxn_idx) self.drawing_drag = False if self._drag_selecting: sel_node_idx |= self.drag_sel_nodes_idx sel_rxn_idx |= self.drag_sel_rxns_idx sel_comp_idx |= self.drag_sel_comps_idx # Flag that indicates whether there are nodes/comps not selected but within # the drag-selection rectangle if len(sel_node_idx) + len(sel_rxn_idx) + len(sel_comp_idx) != orig_count: self.drawing_drag = True self.sel_nodes = [n for n in self._nodes if n.index in sel_node_idx] self.sel_reactions = [r for r in self._reactions if r.index in sel_rxn_idx] self.sel_comps = [c for c in self._compartments if c.index in sel_comp_idx] def _SelectionChanged(self): """Callback passed to observer for when the node/reaction selection has changed.""" if self._in_selection_group: self._selection_dirty = True return node_idx = self.sel_nodes_idx.item_copy() rxn_idx = self.sel_reactions_idx.item_copy() comp_idx = self.sel_compartments_idx.item_copy() # Directly update select_box here, instead of binding to a handler self._select_box.update([n for n in self._nodes if n.index in node_idx], [c for c in self._compartments if c.index in comp_idx]) self._UpdateSelectBoxLayer() for rel in self._reaction_elements: rel.selected = self.sel_reactions_idx.contains(rel.reaction.index) post_event(SelectionDidUpdateEvent(node_indices=node_idx, reaction_indices=rxn_idx, compartment_indices=comp_idx)) cstate.input_mode = cstate.input_mode self._UpdateSelectedLists() def _UpdateSelectBoxLayer(self): """Helper that updates the layer of the select box, depending on what is selected.""" if len(self._select_box.nodes) + len(self._select_box.compartments) == 0: return elements = [cast(CanvasElement, e) for e in self._node_elements if e.node.index in self.sel_nodes_idx] elements += [cast(CompartmentElt, e) for e in self._compartment_elements if e.compartment.index in self.sel_compartments_idx] layers = max(e.layers for e in elements) if isinstance(layers, int): layers = (Canvas.SELECT_BOX_LAYER, layers) else: layers = (Canvas.SELECT_BOX_LAYER,) + layers self.ResetLayer(self._select_box, layers) def InWhichCompartment(self, nodes: List[Node]) -> int: """Return which compartment the given floating rectangles are in, or -1 if not in any. This does not return which compartment the nodes currently are in. Rather, it assumes that the user is dragging the nodes (as in the nodes is floating), and tests from the highest compartment to the lowest, whether the nodes as a whole are considered inside that compartment. Right now, a group of nodes are considered to be inside a compartment iff all the nodes are entirely within in the compartment boundaries. """ for el in self._ElementsHighToLow(): if isinstance(el, CompartmentElt): comp = cast(CompartmentElt, el).compartment comp_rect = comp.rect if all(comp_rect.contains(n.rect) for n in nodes): return comp.index return -1 def RectInWhichCompartment(self, rect: Rect) -> int: """Same as InWhichCompartment but for a single rect""" for el in self._ElementsHighToLow(): if isinstance(el, CompartmentElt): comp = cast(CompartmentElt, el).compartment comp_rect = comp.rect if comp_rect.contains(rect): return comp.index return -1 def DeleteSelectedItems(self): # First, get the list of reaction indices IF the currently selected reactions were deleted. sel_reactions_idx = self.sel_reactions_idx.item_copy() sel_nodes_idx = self.sel_nodes_idx.item_copy() rem_rxn = {r.index for r in self.reactions} - sel_reactions_idx # Second, confirm the selected nodes are free (i.e. not part of a reaction) for orig_node_idx in sel_nodes_idx: orig_node = self.node_idx_map[orig_node_idx] is_alias = orig_node.original_index != -1 # don't need to worry about deleting aliases that are part of reactions if is_alias: continue aliases = [node.index for node in self.nodes if node.original_index == orig_node_idx] for node_idx in chain([orig_node_idx], aliases): if len(self.node_to_rxn[node_idx] & rem_rxn) != 0: self.ShowWarningDialog(("Could not delete node '{}', as one or more reactions " "depend on it or its aliases.").format(orig_node.id)) self.logger.warning("Tried and failed to delete bound node '{}' with index '{}'" .format(orig_node.id, node_idx)) return with self.controller.group_action(): sel_comp_idx = self.sel_compartments_idx.item_copy() for index in sel_reactions_idx: self.controller.delete_reaction(self._net_index, index) for index in sel_nodes_idx: self.controller.delete_node(self._net_index, index) for index in sel_comp_idx: self.controller.delete_compartment(self._net_index, index) post_event(DidDeleteEvent(node_indices=sel_nodes_idx, reaction_indices=sel_reactions_idx, compartment_indices=sel_comp_idx)) def SelectAll(self): with self._SelectGroupEvent(): self.sel_nodes_idx.set_item({n.index for n in self._nodes}) self.sel_reactions_idx.set_item({r.index for r in self._reactions}) self.sel_compartments_idx.set_item({c.index for c in self._compartments}) self.FullRedraw() def SelectAllNodes(self): with self._SelectGroupEvent(): self.sel_nodes_idx.set_item({n.index for n in self._nodes}) self.FullRedraw() def SelectAllReactions(self): with self._SelectGroupEvent(): self.sel_reactions_idx.set_item({r.index for r in self._reactions}) self.FullRedraw() def SelectAllCompartments(self): with self._SelectGroupEvent(): self.sel_compartments_idx.set_item({c.index for c in self._compartments}) self.FullRedraw() def ClearCurrentSelection(self): """Clear the current highest level of selection. If there are reactants or products marked, clear those. OTherwise clear selected nodes and reactions. """ if len(self._reactant_idx) + len(self._product_idx) != 0: self._reactant_idx = set() self._product_idx = set() self.FullRedraw() else: with self._SelectGroupEvent(): self.sel_nodes_idx.set_item(set()) self.sel_reactions_idx.set_item(set()) self.sel_compartments_idx.set_item(set()) self.FullRedraw() def MarkSelectedAsReactants(self): self._reactant_idx = self.sel_nodes_idx.item_copy() # self.FullRedraw() self.LazyRefresh() def MarkSelectedAsProducts(self): self._product_idx = self.sel_nodes_idx.item_copy() # self.FullRedraw() self.LazyRefresh() def CreateReactionFromMarked(self, id='r'): if len(self._reactant_idx) == 0: self.ShowWarningDialog('Could not create reaction: no reactants selected!') return if len(self._product_idx) == 0: self.ShowWarningDialog('Could not create reaction: no products selected!') return if self._reactant_idx == self._product_idx: self.ShowWarningDialog('Could not create reaction: reactants and products are ' 'identical.') return id = self._GetUniqueName(id, [r.id for r in self._reactions]) sources = get_nodes_by_idx(self._nodes, self._reactant_idx) targets = get_nodes_by_idx(self._nodes, self._product_idx) centroid = compute_centroid([n.rect for n in chain(sources, targets)]) reaction = Reaction( id, self.net_index, sources=list(self._reactant_idx), targets=list(self._product_idx), fill_color=get_theme('reaction_fill'), line_thickness=get_theme('reaction_line_thickness'), rate_law='', center_pos=centroid, handle_positions=default_handle_positions(centroid, sources, targets) ) reai = self.controller.add_reaction_g(self._net_index, reaction) self.controller.set_reaction_center(self._net_index, reai, centroid) self._reactant_idx.clear() self._product_idx.clear() with self._SelectGroupEvent(): self.sel_nodes_idx.set_item(set()) self.sel_compartments_idx.set_item(set()) self.sel_reactions_idx.set_item( {self.controller.get_reaction_index(self._net_index, id)}) self.FullRedraw() def CopySelected(self): self._copied_nodes = copy.deepcopy(self.GetSelectedNodes()) # TODO copy reactions and compartments too def CutSelected(self): self.CopySelected() self.DeleteSelectedItems() def Paste(self): pasted_ids = set() all_ids = {n.id for n in self._nodes} with self.controller.group_action(): # get unique IDs for node in self._copied_nodes: node.id = self._GetUniqueName(node.id, pasted_ids, all_ids) node.position += Vec2.repeat(20) pasted_ids.add(node.id) self._nodes.append(node) # add this for the event handlers to see self.controller.add_node_g(self._net_index, node) # update selection *after* end_group(), so as to make sure the canvas is property reset # and updated. For example, if it is not, then the ID of some nodes may be 0 as they are # uninitialized. self.sel_nodes_idx.set_item({self.controller.get_node_index(self._net_index, id) for id in pasted_ids}) def ShowWarningDialog(self, msg: str, caption='Warning'): wx.MessageBox(msg, caption, wx.OK | wx.ICON_WARNING) def AddPluginElement(self, net_index: int, element: CanvasElement): # TODO currently not accounting for net_index self._plugin_elements.add(element) self._widget_elements.add(element) def RemovePluginElement(self, net_index: int, element: CanvasElement): if element not in self._plugin_elements: raise ValueError("Tried to remove an element that is not on canvas.") self._plugin_elements.remove(element) self._widget_elements.remove(element) def GetReactionCentroids(self, net_index: int) -> Dict[int, Vec2]: """Helper method for ReactionForm to get access to the centroid positions. """ return {r.reaction.index: r.bezier.centroid for r in self._reaction_elements}