# from __future__ import annotations
# pylint: disable=maybe-no-member
from abc import abstractmethod
import enum
from functools import partial
from itertools import chain
from math import pi, cos, sin
from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Set, Tuple, Union, cast
from copy import copy
from numpy.lib.utils import source
from rkviewer.canvas.data import CirclePrim, CompositeShape, RectanglePrim, TrianglePrim, Transform, TextPrim, HexagonPrim, LinePrim
import wx
from ..config import Color, get_setting, get_theme
from ..events import (
CanvasEvent, DidChangeCompartmentOfNodesEvent, DidCommitDragEvent, DidMoveBezierHandleEvent, DidMoveReactionCenterEvent, DidResizeCompartmentsEvent, DidResizeNodesEvent, DidMoveCompartmentsEvent,
DidMoveNodesEvent, bind_handler,
post_event, unbind_handler,
)
from ..mvc import IController
from ..utils import change_opacity, even_round, gchain, int_round
from .data import Compartment, HandleData, ModifierTipStyle, Node, Reaction, ReactionBezier, RectData, SpeciesBezier, PolygonPrim, TextAlignment, TextPosition
from .geometry import (
Rect,
Vec2,
clamp_point, clamp_rect_pos,
get_bounding_rect,
padded_rect,
pt_in_circle,
pt_in_rect, rotate_unit, segment_rect_intersection,
pt_on_rect_sides,
)
from .state import InputMode, cstate
from .utils import draw_rect
# SetCursorFn = Callable[[wx.Cursor], None]
Layer = Union[int, Tuple[int, ...]]
[docs]def layer_above(layer: Layer, count: int = 1) -> Layer:
"""
Return the next layer above this layer, without increasing the length of the layer list.
count is optionally the layer number increment.
"""
if count < 1:
raise ValueError('Layer count must be at least 1!')
if isinstance(layer, int):
return layer + count
else:
last = len(layer) - 1
return layer[0:last] + (layer[last] + count,)
[docs]class CanvasElement:
"""Base class for an element positioned on the canvas.
Attributes:
layers: The layer(s) number of this element.
enabled: Whether the element is enabled.
destroyed: Whether the object was destroyed (if this is True then you shouldn't use this)
"""
layers: Layer
enabled: bool
destroyed: bool
def __init__(self, layers: Layer):
if isinstance(layers, int):
layers = (layers,)
self.layers = layers
self.enabled = True
self.destroyed = False
[docs] def set_layers(self, layers: Layer):
if isinstance(layers, int):
layers = (layers,)
self.layers = layers
[docs] def destroy(self):
"""Destroy this element; override this for specific implementations."""
self.destroyed = True
[docs] def pos_inside(self, logical_pos: Vec2) -> bool:
"""Returns whether logical_pos is inside the diplayed shape of this element."""
return False
[docs] @abstractmethod
def on_paint(self, gc: wx.GraphicsContext):
"""Paint the shape onto the given GraphicsContext.
This draws onto the scrolled canvas, i.e. the position of the drawn item will respond to
scrolling, so you don't need to account for that.
"""
pass
[docs] def on_paint_cue(self, gc: wx.GraphicsContext):
'''This is called to paint special visual cues tha tthe element may need to display'''
pass
[docs] def on_mouse_enter(self, logical_pos: Vec2) -> bool:
"""Handler for when the mouse has entered the shape."""
return False
[docs] def on_mouse_leave(self, logical_pos: Vec2) -> bool:
"""Handler for when the mouse has exited the shape"""
return False
[docs] def on_mouse_move(self, logical_pos: Vec2) -> bool:
"""Handler for when the mouse moves inside the shape, with the left mouse button up."""
return False
[docs] def on_mouse_drag(self, logical_pos: Vec2, rel_pos: Vec2) -> bool:
"""Handler for when the mouse drags inside the shape, with the left mouse button down."""
return False
[docs] def on_left_down(self, logical_pos: Vec2) -> bool:
"""Handler for when the mouse left button is pressed down inside the shape."""
return False
[docs] def on_left_up(self, logical_pos: Vec2) -> bool:
"""Handler for when the mouse left button is springs up inside the shape."""
return False
[docs] def bounding_rect(self) -> Rect:
"""Return the bounding rectangle of the element."""
return Rect(Vec2(), Vec2())
[docs]class NodeElement(CanvasElement):
"""CanvasElement for nodes."""
node: Node
canvas: Any
# HACK no type specified for canvas since otherwise there would be circular dependency
def __init__(self, node: Node, canvas, layers: Layer):
super().__init__(layers)
self.node = node
self.canvas = canvas
self.gfont = None # In the future
self.font_scale = 1
[docs] def pos_inside(self, logical_pos: Vec2) -> bool:
return pt_in_rect(logical_pos, self.node.s_rect)
[docs] def on_paint(self, gc: wx.GraphicsContext):
if self.gfont is None or self.font_scale != cstate.scale:
font = wx.Font(wx.FontInfo(10))
self.gfont = gc.CreateFont(font, wx.BLACK)
gc.SetFont(self.gfont)
# boundaryFactor = 1
# if not self.node.floatingNode:
# boundaryFactor = 2 # Store this in a theme?
s_aligned_rect = self.node.s_rect.aligned()
# aligned_border_width = max(even_round(
# self.node.border_width * boundaryFactor), 2)
width, height = s_aligned_rect.size
assert self.node.composite_shape is not None
draw_composite_shape(
gc,
self.node.rect,
self.node)
if self.node.lockNode:
lock_color = self.node.border_color or Color(255, 0, 0)
pen = gc.CreatePen(wx.GraphicsPenInfo(
lock_color.to_wxcolour()).Width(2))
gc.SetPen(pen)
path = gc.CreatePath()
path.AddCircle(self.node.position.x, self.node.position.y, .1*height)
gc.StrokePath(path)
if not self.node.floatingNode:
boundary_color = self.node.border_color or Color(255, 0, 0)
pen = gc.CreatePen(wx.GraphicsPenInfo(
boundary_color.to_wxcolour()).Width(2))
gc.SetPen(pen)
path = gc.CreatePath()
path.AddRectangle(self.node.position.x-round(self.node.border_width/2 + 2)-2,
self.node.position.y-round(self.node.border_width/2 + 2)-2,
2*round(self.node.border_width/2 + 2)+self.node.size.x+4,
2*round(self.node.border_width/2 + 2)+self.node.size.y+4)
gc.StrokePath(path)
[docs] def on_left_down(self, _: Vec2):
return True
[docs] def bounding_rect(self) -> Rect:
return self.node.rect
[docs]class BezierHandle(CanvasElement):
"""Class that keeps track of a Bezier control handle tip.
Attributes:
HANDLE_RADIUS: radius of the control handle.
data: The associated HandleData>
on_moved: The function called when the handle is moved.
on_dropped: The function called when the handle is dropped (i.e. mouse up).
reaction: The associated Reaction.
twin: The twin BezierHandle; used only for the center handles.
node_idx: The index of the node associated with this handle. -1 if this is a source
centroid handle, and -2 if this is a target centroid handle.
"""
HANDLE_RADIUS = 5 # Radius of the control handle
data: HandleData
on_moved: Callable[[Vec2], None]
on_dropped: Callable[[Vec2], None]
reaction: Reaction
twin = Any
node_idx: int
def __init__(self, data: HandleData, layer: Layer, on_moved: Callable[[Vec2], None],
on_dropped: Callable[[Vec2], None], reaction: Reaction, node_idx: int):
super().__init__(layer)
self.data = data
self.on_moved = on_moved
self.on_dropped = on_dropped
self.reaction = reaction
self.hovering = False
self.enabled = False
self.twin = None
self.node_idx = node_idx
[docs] def pos_inside(self, logical_pos: Vec2):
return pt_in_circle(logical_pos, BezierHandle.HANDLE_RADIUS, self.data.tip)
def _paint(self, gc: wx.GraphicsContext, handle_color: wx.Colour):
brush = wx.Brush(handle_color)
pen = gc.CreatePen(wx.GraphicsPenInfo(handle_color))
sbase = self.data.base
stip = self.data.tip
gc.SetPen(pen)
# Draw handle lines
gc.StrokeLine(*sbase, *stip)
# Draw handle circles
gc.SetBrush(brush)
gc.DrawEllipse(stip.x - BezierHandle.HANDLE_RADIUS,
stip.y - BezierHandle.HANDLE_RADIUS,
2 * BezierHandle.HANDLE_RADIUS, 2 * BezierHandle.HANDLE_RADIUS)
[docs] def on_paint(self, gc: wx.GraphicsContext):
"""Paint the handle as given by its base and tip positions, highlighting it if hovering."""
assert self.data.base is not None
if self.reaction.bezierCurves:
c = get_theme('handle_color')
self._paint(gc, c)
[docs] def on_paint_cue(self, gc: wx.GraphicsContext):
if self.hovering and self.reaction.bezierCurves:
assert self.data.base is not None
c = get_theme('highlighted_handle_color')
self._paint(gc, c)
[docs] def on_mouse_enter(self, logical_pos: Vec2) -> bool:
self.hovering = True
if self.twin:
self.twin.hovering = True
return True
[docs] def on_mouse_leave(self, logical_pos: Vec2) -> bool:
self.hovering = False
if self.twin:
self.twin.hovering = False
return True
[docs] def on_left_down(self, logical_pos: Vec2) -> bool:
return True
[docs] def on_mouse_drag(self, logical_pos: Vec2, rel_pos: Vec2) -> bool:
self.data.tip += rel_pos
self.on_moved(self.data.tip)
neti = 0
post_event(DidMoveBezierHandleEvent(neti, self.reaction.index,
self.node_idx, by_user=True, direct=True))
return True
[docs] def on_left_up(self, logical_pos: Vec2):
self.on_dropped(self.data.tip)
return True
[docs]class ReactionCenter(CanvasElement):
parent: 'ReactionElement'
_moved: bool
def __init__(self, parent: 'ReactionElement', layers: Layer):
super().__init__(layers)
self.parent = parent
self._moved = False
self.hovering = False
def _paint(self, gc: wx.GraphicsContext, color: wx.Colour):
pen = wx.Pen(color)
brush = wx.Brush(color)
gc.SetPen(pen)
gc.SetBrush(brush)
radius = get_theme('reaction_radius')
center = self.parent.bezier.real_center - Vec2.repeat(radius)
gc.DrawEllipse(center.x, center.y, radius * 2, radius * 2)
[docs] def on_paint(self, gc: wx.GraphicsContext):
if not self.parent.selected:
return
# draw centroid
color = self.parent.reaction.fill_color
if self.parent.selected:
color = get_theme('handle_color')
self._paint(gc, color)
[docs] def on_paint_cue(self, gc: wx.GraphicsContext):
if self.parent.selected and self.hovering:
color = get_theme('highlighted_handle_color')
self._paint(gc, color)
[docs] def on_left_down(self, logical_pos: Vec2) -> bool:
# If not selected, then nothing is done to prevent accidental dragging
return True
[docs] def on_mouse_drag(self, logical_pos: Vec2, rel_pos: Vec2) -> bool:
offset = rel_pos
reaction = self.parent.reaction
reaction.center_pos = self.parent.bezier.real_center + offset
self.parent.bezier.center_moved(offset)
self._moved = True
net_index = 0
post_event(DidMoveReactionCenterEvent(net_index, reaction.index, offset, True))
return True
[docs] def on_left_up(self, logical_pos: Vec2) -> bool:
ctrl = self.parent.controller
neti = self.parent.canvas.net_index
reai = self.parent.reaction.index
with ctrl.group_action():
ctrl.set_reaction_center(neti, reai, self.parent.reaction.center_pos)
ctrl.set_center_handle(neti, reai, self.parent.reaction.src_c_handle.tip)
post_event(DidCommitDragEvent(self))
self._moved = False
return True
[docs] def pos_inside(self, logical_pos: Vec2) -> bool:
# TODO works witih zoom?
radius = get_theme('reaction_radius')
return pt_in_circle(self.parent.bezier.real_center, radius, logical_pos)
[docs] def on_mouse_enter(self, logical_pos: Vec2) -> bool:
self.hovering = True
return True
[docs] def on_mouse_leave(self, logical_pos: Vec2) -> bool:
self.hovering = False
return True
[docs] def bounding_rect(self) -> Rect:
radius = get_theme('reaction_radius')
return Rect(self.parent.bezier.real_center - radius, Vec2.repeat(radius) * 2)
# Uniquely identifies nodes in a reaction: (regular node index; whether the node is a reactant)
RIndex = Tuple[int, bool]
[docs]class ReactionElement(CanvasElement):
"""CanvasElement for reactions.
Note that if new nodes are constructed, all ReactionBezier instances that use these nodes should
be re-constructed with the new nodes. On the other hand, if the nodes are merely modified, the
corresponding update methods should be called.
"""
reaction: Reaction
center_el: ReactionCenter
index_to_bz: Dict[RIndex, SpeciesBezier]
bezier: ReactionBezier
bhandles: List[BezierHandle]
moved_handler_id: int
#: Set of indices of the nodes that have been moved, but not committed.
_dirty_node_indices: Set[int]
#: Works in tandem with _dirty_indices. True if all nodes of the reaction are being moved.
_moving_all: bool
_selected: bool
canvas: Any # avoid circular dependency
controller: IController
# HACK no type for canvas since otherwise there is circular dependency
def __init__(self, reaction: Reaction, bezier: ReactionBezier, canvas, layers: Layer,
handle_layer: Layer):
super().__init__(layers)
self.reaction = reaction
self.containing_node_indices = set(chain(reaction.sources, reaction.targets))
self.bezier = bezier
self.moved_handler_id = bind_handler(DidMoveNodesEvent, self.nodes_moved)
# i is 0 for source Beziers, but 1 for dest Beziers. "not" it to get the correct bool.
self.index_to_bz = {(bz.node_idx, not gi): bz
for gi, bz in gchain(bezier.src_beziers,
bezier.dest_beziers)}
self.canvas = canvas
self.controller = canvas.controller
self._hovered_handle = None
self._dirty_indices = set()
self._moving_all = False
self.bhandles = list()
self._selected = False
neti = canvas.net_index
reai = reaction.index
ctrl = canvas.controller
# create elements for species
for gi, sb in gchain(bezier.src_beziers, bezier.dest_beziers):
dropped_func = self.make_drop_handle_func(ctrl, neti, reai, sb.node_idx, not gi)
el = BezierHandle(sb.handle, handle_layer,
bezier.make_handle_moved_func(sb), dropped_func, reaction, sb.node_idx)
self.bhandles.append(el)
def centroid_handle_dropped(p: Vec2):
with ctrl.group_action():
ctrl.set_center_handle(neti, reai, reaction.src_c_handle.tip)
# NOTE important to do this within the group action
post_event(DidCommitDragEvent(self))
src_bh = BezierHandle(reaction.src_c_handle, handle_layer,
lambda _: bezier.src_handle_moved(),
centroid_handle_dropped, reaction, -1)
dest_bh = BezierHandle(reaction.dest_c_handle, handle_layer,
lambda _: bezier.dest_handle_moved(),
centroid_handle_dropped, reaction, -2)
src_bh.twin = dest_bh
dest_bh.twin = src_bh
self.bhandles.append(src_bh)
self.bhandles.append(dest_bh)
center_layers = layer_above(layers, count=2)
self.center_el = ReactionCenter(self, center_layers)
[docs] def make_drop_handle_func(self, ctrl: IController, neti: int, reai: int, nodei: int,
is_source: bool):
if is_source:
def ret(p):
ctrl.set_src_node_handle(neti, reai, nodei, p)
post_event(DidCommitDragEvent(self))
return ret
else:
# Avoid IDE warnings
def ret1(p):
ctrl.set_dest_node_handle(neti, reai, nodei, p)
post_event(DidCommitDragEvent(self))
return ret1
@property
def selected(self) -> bool:
return self._selected
@selected.setter
def selected(self, val: bool):
# Enable/disable Handles based on whether the curve is selected
self._selected = val
for bz in self.bhandles:
bz.enabled = val
self.center_el.enabled = val
[docs] def nodes_moved(self, evt: CanvasEvent):
"""Handler for after a node has moved."""
# If already moving (i.e. self._dirty_indices is not empty), then skip forward
c_evt = cast(DidMoveNodesEvent, evt)
node_indices = set(c_evt.node_indices) & self.containing_node_indices
if not node_indices:
return
offset = c_evt.offset
rects = [self.canvas.node_idx_map[idx].rect for idx in chain(
self.reaction.sources, self.reaction.targets)]
self.bezier.nodes_moved(rects)
if len(self._dirty_indices) == 0:
self._dirty_indices = node_indices
my_indices = set(chain(self.reaction.sources, self.reaction.targets))
self._moving_all = my_indices <= self._dirty_indices
neti = 0
for i, idx in enumerate(node_indices):
for in_src in [True, False]:
if (idx, in_src) in self.index_to_bz:
bz = self.index_to_bz[(idx, in_src)]
off = offset if isinstance(offset, Vec2) else offset[i]
bz.handle.tip += off
post_event(DidMoveBezierHandleEvent(neti, self.reaction.index, bz.node_idx,
by_user=True, direct=False))
bz.update_curve(self.bezier.real_center)
if self._moving_all and isinstance(offset, Vec2):
# Only move src_handle_tip if moving all nodes and they are moved by the same amount.
self.reaction.src_c_handle.tip += offset
# move center pos as well if it is not auto-set as the centroid
if self.reaction.center_pos:
self.reaction.center_pos += offset
self.bezier.src_handle_moved()
post_event(DidMoveBezierHandleEvent(neti, self.reaction.index, -1, by_user=True,
direct=False))
[docs] def commit_node_pos(self):
"""Handler for after the controller is told to move a node."""
ctrl: IController = self.canvas.controller
neti = self.canvas.net_index
reai = self.reaction.index
for bz in self.bezier.src_beziers:
if bz.node_idx in self._dirty_indices:
ctrl.set_src_node_handle(neti, reai, bz.node_idx, bz.handle.tip)
for bz in self.bezier.dest_beziers:
if bz.node_idx in self._dirty_indices:
ctrl.set_dest_node_handle(neti, reai, bz.node_idx, bz.handle.tip)
if self._moving_all:
ctrl.set_center_handle(neti, reai, self.reaction.src_c_handle.tip)
if self.reaction.center_pos:
ctrl.set_reaction_center(neti, reai, self.reaction.center_pos)
self._dirty_indices = set()
[docs] def destroy(self):
unbind_handler(self.moved_handler_id)
super().destroy()
[docs] def pos_inside(self, logical_pos: Vec2) -> bool:
return self.bezier.is_mouse_on(logical_pos)
[docs] def on_mouse_enter(self, logical_pos: Vec2):
self.on_mouse_move(logical_pos)
[docs] def on_left_down(self, logical_pos: Vec2) -> bool:
return True # Return True so that this can be selected
[docs] def on_paint(self, gc: wx.GraphicsContext):
self.bezier.do_paint(gc, self.reaction.fill_color, self.selected)
MOD_NODE_PAD = 10
RXN_PAD = 15
MODIFIER_RADIUS = 3
TEE_LENGTH = 5
line_width = get_theme('modifier_line_width')
pen = gc.CreatePen(wx.GraphicsPenInfo(get_theme('modifier_line_color')).Width(line_width))
brush = gc.CreateBrush(wx.Brush(get_theme('modifier_line_color')))
gc.SetPen(pen)
gc.SetBrush(brush)
# Draw modifier lines
for modifier in self.reaction.modifiers:
mod_node = self.canvas.node_idx_map[modifier]
rect = mod_node.rect
clipping_rect = padded_rect(rect, MOD_NODE_PAD)
node_center = rect.center_point
rxn_center = self.bezier.real_center
# If too close, don't draw. Pad rectangle and circle with 1 to avoid floating point
# shenanigans
if pt_in_rect(rxn_center, padded_rect(clipping_rect, 1)):
continue
elif pt_in_circle(node_center, RXN_PAD + 1, rxn_center):
continue
# create segment
segment = (node_center, rxn_center)
diff = node_center - rxn_center
rxn_intersection = (rxn_center + diff.normalized(RXN_PAD))
node_intersection = segment_rect_intersection(segment, clipping_rect)
# gc.StrokeLine(*node_intersection, *rxn_intersection)
path = gc.CreatePath()
path.MoveToPoint(*node_intersection)
path.AddLineToPoint(*rxn_intersection)
if self.reaction.modifier_tip_style == ModifierTipStyle.CIRCLE:
path.AddCircle(*rxn_intersection, MODIFIER_RADIUS)
elif self.reaction.modifier_tip_style == ModifierTipStyle.TEE:
# draw T-shaped tip
ortho = rotate_unit(diff, pi / 2)
pt1 = rxn_intersection + ortho * TEE_LENGTH
pt2 = rxn_intersection - ortho * TEE_LENGTH
path.MoveToPoint(*pt1)
path.AddLineToPoint(*pt2)
gc.StrokePath(path)
gc.FillPath(path)
# path.MoveToPoint(0.0, 50.0)
[docs] def bounding_rect(self) -> Rect:
return self.bezier.get_bounding_rect()
[docs]class CompartmentElt(CanvasElement):
def __init__(self, compartment: Compartment, major_layer: int, minor_layer: int):
super().__init__((major_layer, minor_layer))
self.compartment = compartment
[docs] def pos_inside(self, logical_pos: Vec2) -> bool:
thickness = max(int(self.compartment.border_width), 1)
return pt_on_rect_sides(logical_pos, self.compartment.rect, thickness)
[docs] def on_left_down(self, logical_pos: Vec2) -> bool:
return True
def _paint(self, gc: wx.GraphicsContext, highlight: bool):
rect = Rect(self.compartment.position,
self.compartment.size)
if highlight:
border = wx.Colour(255, 255, 255, 100)
fill = wx.Colour(255, 255, 255, 100)
else:
border = self.compartment.border
fill = self.compartment.fill
draw_rect(gc, rect, border=border,
border_width=self.compartment.border_width, fill=fill,
corner_radius=get_theme('comp_corner_radius'))
[docs] def on_paint(self, gc: wx.GraphicsContext):
self._paint(gc, False)
[docs] def highlight_paint(self, gc: wx.GraphicsContext):
self._paint(gc, True)
[docs] def bounding_rect(self) -> Rect:
return self.compartment.rect
[docs]class SelectBox(CanvasElement):
"""Class that represents a select box, i.e. the bounding box draw around the selected nodes.
Supports moving and resizing operations.
Attributes:
CURSOR_TYPES: List of cursor types starting with that for the top-left handle and going
clockwise.
nodes: List of selected nodes, as contained in this select box.
related_elts: List of NodeElements related to each node instance; matches the node list
1-1.
bounding_rect: The exact bounding rectangle (without padding).
mode: Current input mode of the SelectBox.
Note:
The behavior of the SelectBox depends on the nodes and compartments selected, but not the
reactions. The cases of behaviors are documented here:
1) Only compartments are selected. Nodes within these compartments are dragged along with
them, but they are not resized.
3) Only nodes are selected, and they are all in the same compartment. In this case, the
nodes may be moved normally. They also may be moved outside of their compartment to be
assigned to another compartment (this is the only case where this is possible). However
note that the nodes may not be resized to be larger than the containing compartment.
3) Otherwise, there are two cases depending on if the selected nodes are in the union of
the selected compartments.
a) If the selected nodes are entirely contained in the list of selected compartments,
then everything is moved and resized together, as usual.
b) Otherwise (i.e. some node is not in any selected compartment), then dragging and
resizing are disabled.
Note that in case 2), if all selected nodes are in the base compartment (i.e. no
compartment), then the base compartment is assumed to be selected, and resizing and moving
work as usual.
"""
CURSOR_TYPES = [wx.CURSOR_SIZENWSE, wx.CURSOR_SIZENS, wx.CURSOR_SIZENESW, wx.CURSOR_SIZEWE,
wx.CURSOR_SIZENWSE, wx.CURSOR_SIZENS, wx.CURSOR_SIZENESW, wx.CURSOR_SIZEWE]
HANDLE_MULT = [Vec2(), Vec2(1/2, 0), Vec2(1, 0), Vec2(1, 1/2),
Vec2(1, 1), Vec2(1/2, 1), Vec2(0, 1), Vec2(0, 1/2)]
nodes: List[Node]
node_indices: List[int]
compartments: List[Compartment]
comp_indices: List[int]
# List of nodes that are not selected, but are within selected compartments. Used only
# for SMode.CONTAINED
peripheral_nodes: List[Node]
related_elts: List[CanvasElement]
bounding_rect: Rect
_padding: float #: padding for the bounding rectangle around the selected nodes
_drag_rel: Vec2 #: relative position of the mouse to the bounding rect when dragging started
#: whether the node was drag-moved between left_down and left_up.
_did_move: bool
_orig_rpos: List[Vec2] #: relative positions of the comps and nodes to the select box
_orig_rsize: List[Vec2] #: sizes of the comps and nodes to the select box
#: relative positions of the compartments and nodes to cursor; updated when dragging starts
_rel_positions: List[Vec2]
_resize_handle: int #: the node resize handle.
node_min_ratio: Optional[Vec2]
comp_min_ratio: Optional[Vec2]
#: the minimum resize ratio for each axis, to avoid making the nodes too small
_min_resize_ratio: Vec2
#: the bounding rect when dragging/resizing started
_orig_rect: Optional[Rect]
_bounds: Rect #: the bounds that the bounding rect may not exceed
special_mode: 'SelectBox.SMode'
[docs] class Mode(enum.Enum):
IDLE = 0
MOVING = 1
RESIZING = 2
[docs] class SMode(enum.Enum):
"""For what this does, see "Notes" section of the class documentation."""
COMP_ONLY = 0
"""Only compartments are selected."""
NODES_IN_ONE = 1
"""Only nodes are selected, and they are in a single compartment."""
CONTAINED = 2
"""Nodes are entirely contained in the selected compartments, or they are all in the base
compartment.
"""
NOT_CONTAINED = 3
"""Nodes are not entirely contained in the selected compartments."""
def __init__(self, canvas, nodes: List[Node], compartments: List[Compartment], bounds: Rect,
controller: IController, net_index: int, layer: int):
super().__init__(layer)
self.canvas = canvas
self.peripheral_nodes = list()
self.update(nodes, compartments)
self.controller = controller
self.net_index = net_index
self._mode = SelectBox.Mode.IDLE
self._drag_rel = Vec2()
self._did_move = False
self._orig_rpos = list()
self._orig_rsizes = list()
self._rel_positions = list()
self._orig_rect = None
self._resize_handle = -1
self._min_resize_ratio = Vec2()
self._hovered_part = -2
self._bounds = bounds
@property
def mode(self):
return self._mode
[docs] def update(self, nodes: List[Node], compartments: List[Compartment]):
self.nodes = nodes
self.compartments = compartments
self.node_indices = [n.index for n in self.nodes]
self.comp_indices = [c.index for c in self.compartments]
if len(nodes) + len(compartments) > 0:
if len(nodes) == 1 and len(compartments) == 0:
# If only one node is selected, reduce padding
self._padding = get_theme('select_outline_padding')
else:
self._padding = get_theme('select_box_padding')
# Align bounding box if only one node is selected, see NodeElement::on_paint for
# explanations. Note that self._padding should be an integer
self._padding = int_round(self._padding)
self.bounding_rect = get_bounding_rect(
[n.rect for n in nodes] + [c.rect for c in compartments])
selected_comps = set(c.index for c in compartments) | {-1}
# Determine SMode
if len(nodes) == 0:
self.special_mode = SelectBox.SMode.COMP_ONLY
else:
assoc_comps = {n.comp_idx for n in nodes}
if len(compartments) == 0:
if len(assoc_comps) == 1:
# Nodes are in one compartment
self.special_mode = SelectBox.SMode.NODES_IN_ONE
else:
# Cannot possibly contain
self.special_mode = SelectBox.SMode.NOT_CONTAINED
else:
if selected_comps >= assoc_comps:
self.special_mode = SelectBox.SMode.CONTAINED
else:
self.special_mode = SelectBox.SMode.NOT_CONTAINED
# Compute peripheral nodes
if self.special_mode == SelectBox.SMode.NOT_CONTAINED:
self.peripheral_nodes = list()
else:
peri_indices = set()
for comp in compartments:
if comp.index in selected_comps:
peri_indices |= set(comp.nodes)
peri_indices -= set(n.index for n in nodes)
self.peripheral_nodes = [self.canvas.node_idx_map[i] for i in peri_indices]
[docs] def outline_rect(self) -> Rect:
"""Helper that returns the scaled, padded bounding rectangle."""
return padded_rect((self.bounding_rect).aligned(), self._padding)
def _resize_handle_rects(self):
"""Helper that computes the scaled positions and sizes of the resize handles.
Returns:
A list of (pos, size) tuples representing the resize handle
rectangles. They are ordered such that the top-left handle is the first element, and
all other handles follow in clockwise fashion.
"""
outline_rect = self.outline_rect()
pos, size = outline_rect.as_tuple()
centers = [pos, pos + Vec2(size.x / 2, 0),
pos + Vec2(size.x, 0), pos + Vec2(size.x, size.y / 2),
pos + size, pos + Vec2(size.x / 2, size.y),
pos + Vec2(0, size.y), pos + Vec2(0, size.y / 2)]
centers = [pos + size.elem_mul(m) for m in SelectBox.HANDLE_MULT]
side = get_theme('select_handle_length')
return [Rect(c - Vec2.repeat(side/2), Vec2.repeat(side)) for c in centers]
def _resize_handle_pos(self, n: int):
pos, size = self.outline_rect().as_tuple()
assert n >= 0 and n < 8
return pos + size.elem_mul(SelectBox.HANDLE_MULT[n])
def _pos_inside_part(self, logical_pos: Vec2) -> int:
"""Helper for determining if logical_pos is within which, if any, part of this widget.
Returns:
The handle index (0-3) if pos is within a handle, -1 if pos is not within a handle
but within a node or on the compartment edge, or -2 if pos is outside.
"""
if len(self.nodes) + len(self.compartments) == 0:
return -2
rects = self._resize_handle_rects()
for i, rect in enumerate(rects):
if pt_in_rect(logical_pos, rect):
return i
nrects = [n.rect for n in self.nodes]
crects = [c.rect for c in self.compartments]
if any(pt_in_rect(logical_pos, r) for r in nrects) \
or any(pt_on_rect_sides(logical_pos, r) for r in crects):
return -1
else:
return -2
[docs] def pos_inside(self, logical_pos: Vec2):
return self._pos_inside_part(logical_pos) != -2
[docs] def on_mouse_enter(self, logical_pos: Vec2):
self.on_mouse_move(logical_pos)
[docs] def on_mouse_move(self, logical_pos: Vec2):
if cstate.input_mode != InputMode.SELECT:
return False # clearly, don't change the cursor if we're not in select mode
self._hovered_part = self._pos_inside_part(logical_pos)
if self._hovered_part >= 0:
cursor = SelectBox.CURSOR_TYPES[self._hovered_part]
self.canvas.SetCursor(wx.Cursor(cursor))
elif self._hovered_part == -1:
self.canvas.SetCursor(wx.Cursor(wx.CURSOR_SIZING))
return True
[docs] def on_mouse_leave(self, logical_pos: Vec2):
self._hovered_part = -2
# HACK re-set input_mode with the same value to make canvas update the cursor
# See issue #9 for details
cstate.input_mode = cstate.input_mode
return True
[docs] def on_paint(self, gc: wx.GraphicsContext):
if len(self.nodes) + len(self.compartments) > 0:
outline_width = max(even_round(get_theme('select_outline_width')), 2)
pos, size = self.outline_rect().as_tuple()
# draw main outline
draw_rect(gc, Rect(pos, size), border=get_theme('handle_color'),
border_width=outline_width, corner_radius=0)
for handle_rect in self._resize_handle_rects():
# convert to device position for drawing
draw_rect(gc, handle_rect, fill=get_theme('handle_color'))
[docs] def map_rel_pos(self, positions: Iterable[Vec2]) -> List[Vec2]:
temp = [p - self._orig_rect.position - Vec2.repeat(self._padding)
for p in positions]
return temp
[docs] def on_left_down(self, logical_pos: Vec2):
if len(self.nodes) + len(self.compartments) == 0:
return False
# if multi-selecting and clicked on a node/reaction, then the user must mean to de-select
# that element. In this exceptional case we return False so that canvas can continue the
# pos_inside loop and find the node/reaction in question later.
if cstate.multi_select:
for elt in self.related_elts:
if elt.pos_inside(logical_pos):
return False
elif len(self.compartments) != 0:
# make sure that user can select items inside compartment, even if the compartment is
# itself selected.
# for elt in self.related_elts:
# if elt.pos_inside(logical_pos):
# if isinstance(elt, ReactionElement):
# return False
# elif isinstance(elt, NodeElement) and elt.node.comp_idx != -1:
# return False
# else:
# break
pass
handle = self._hovered_part
# assert self._mode == SelectBox.Mode.IDLE
if handle >= 0:
self._mode = SelectBox.Mode.RESIZING
self._resize_handle = handle
# calculate minimum resize ratio, enforcing min size constraints on nodes and comps
self._update_min_resize_ratio()
#self._orig_rect = self.outline_rect()
# Take unaligned bounding rect as orig_rect for better accuracy
self._orig_rect = padded_rect(
(self.bounding_rect), self._padding)
# relative starting positions to the select box
orig_node_pos = self.map_rel_pos((n.position for n in self.nodes))
orig_comp_pos = self.map_rel_pos((c.position for c in self.compartments))
orig_node_sizes = [n.size for n in self.nodes]
orig_comp_sizes = [c.size for c in self.compartments]
self._orig_rpos = orig_comp_pos + orig_node_pos
self._orig_rsizes = orig_comp_sizes + orig_node_sizes
self._resize_handle_offset = self._resize_handle_pos(handle) - logical_pos
return True
elif handle == -1:
self._mode = SelectBox.Mode.MOVING
# relative starting positions to the mouse positions
rel_node_pos = [n.position -
logical_pos for n in self.nodes if not n.lockNode]
rel_comp_pos = [c.position - logical_pos for c in self.compartments]
self._rel_positions = rel_comp_pos + rel_node_pos
self._drag_rel = self.bounding_rect.position - logical_pos
return True
return False
[docs] def compute_min_ratio(self) -> Tuple[Optional[Vec2], Optional[Vec2]]:
"""Compute minimum size ratio resizing nodes and compartments.
Returns:
A tuple containing (node mininum resize ratio, comp minimum resize ratio). Each ratio
may be None, in the case that no elements of that type is selected.
"""
node_min_ratio = None
comp_min_ratio = None
if len(self.nodes) != 0:
min_width = min(n.size.x for n in self.nodes)
min_height = min(n.size.y for n in self.nodes)
node_min_ratio = Vec2(get_setting('min_node_width') / min_width,
get_setting('min_node_height') / min_height)
if len(self.compartments) != 0:
min_comp_width = min(c.size.x for c in self.compartments)
min_comp_height = min(c.size.y for c in self.compartments)
comp_min_ratio = Vec2(get_setting('min_comp_width') / min_comp_width,
get_setting('min_comp_height') / min_comp_height)
for node in self.peripheral_nodes:
comp = self.canvas.comp_idx_map[node.comp_idx]
ratio = node.size.elem_div(comp.size)
comp_min_ratio = comp_min_ratio.reduce2(max, ratio)
return node_min_ratio, comp_min_ratio
def _update_min_resize_ratio(self):
node_min_ratio, comp_min_ratio = self.compute_min_ratio()
if node_min_ratio is not None and comp_min_ratio is not None:
self._min_resize_ratio = node_min_ratio.reduce2(max, comp_min_ratio)
elif node_min_ratio is not None:
self._min_resize_ratio = node_min_ratio
elif comp_min_ratio is not None:
self._min_resize_ratio = comp_min_ratio
else:
assert False, 'Cannot possibly click on handle when nothing is selected.'
[docs] def on_left_up(self, logical_pos: Vec2):
assert len(self.nodes) + len(self.compartments) != 0
if self._mode == SelectBox.Mode.MOVING:
if self._did_move:
self._did_move = False
self._commit_move()
elif self._mode == SelectBox.Mode.RESIZING:
assert not self._did_move
with self.controller.group_action():
for node in self.peripheral_nodes:
self.controller.move_node(self.net_index, node.index, node.position)
for node in self.nodes:
self.controller.move_node(self.net_index, node.index, node.position)
self.controller.set_node_size(self.net_index, node.index, node.size)
for comp in self.compartments:
self.controller.move_compartment(self.net_index, comp.index, comp.position)
self.controller.set_compartment_size(self.net_index, comp.index, comp.size)
post_event(DidCommitDragEvent(self))
# Need to do this in case _special_mode == NOT_CONTAINED, so that the mouse has now
# left the handle. on_mouse_leave is not triggered because it's considered to be dragging.
self._hovered_part = self._pos_inside_part(logical_pos)
self._mode = SelectBox.Mode.IDLE
[docs] def on_mouse_drag(self, logical_pos: Vec2, rel_pos: Vec2) -> bool:
assert self._mode != SelectBox.Mode.IDLE
rect_data = cast(List[RectData], self.compartments) + cast(List[RectData], self.nodes)
if self.special_mode == SelectBox.SMode.NOT_CONTAINED:
# Return True since we still want this to appear to be dragging, just not working.
return True
if self._mode == SelectBox.Mode.RESIZING:
rect_data = cast(List[RectData], self.compartments) + cast(List[RectData], self.nodes)
# TODO move the orig_rpos, etc. code to update()
bounds = self._bounds
if self.special_mode == SelectBox.SMode.NODES_IN_ONE:
# constrain resizing to within this compartment
if self.nodes[0].comp_idx != -1:
containing_comp = self.canvas.GetCompartment(self.nodes[0].comp_idx)
assert containing_comp is not None
bounds = containing_comp.rect
self._resize(logical_pos, rect_data, self._orig_rpos, self._orig_rsizes, bounds)
else:
nodes = [n for n in self.nodes if not n.lockNode]
rect_data = cast(List[RectData], self.compartments) + cast(List[RectData], nodes)
if len(rect_data) == 0:
return True
self._move(logical_pos, rect_data, self._rel_positions)
return True
def _resize(self, pos: Vec2, rect_data: List[RectData], orig_pos: List[Vec2],
orig_sizes: List[Vec2], bounds: Rect):
"""Helper that performs resize on the bounding box, given the logical mouse position."""
# STEP 1, get new rect vertices
# see class comment for resize handle format. For side-handles, get the vertex in the
# counter-clockwise direction
dragged_idx = self._resize_handle // 2
# get the vertex opposite dragged idx as fixed_idx
fixed_idx = int((dragged_idx + 2) % 4)
orig_dragged_point = self._orig_rect.nth_vertex(dragged_idx)
cur_dragged_point = self.outline_rect().nth_vertex(dragged_idx)
fixed_point = self._orig_rect.nth_vertex(fixed_idx)
target_point = pos + self._resize_handle_offset
# if a side-handle, then only resize one axis
if self._resize_handle % 2 == 1:
if self._resize_handle % 4 == 1:
# vertical resize; keep x the same
target_point = target_point.swapped(0, orig_dragged_point.x)
else:
assert self._resize_handle % 4 == 3
target_point = target_point.swapped(1, orig_dragged_point.y)
# clamp target point
target_point = clamp_point(target_point, bounds)
# STEP 2, get and validate rect ratio
# raw difference between (current/target) dragged vertex and fixed vertex. Raw as in this
# is the visual difference shown on the bounding rect.
orig_diff = orig_dragged_point - fixed_point
target_diff = target_point - fixed_point
signs = orig_diff.elem_mul(target_diff)
# bounding_rect flipped?
if signs.x < 0:
target_point = target_point.swapped(0, cur_dragged_point.x)
if signs.y < 0:
target_point = target_point.swapped(1, cur_dragged_point.y)
# take absolute value and subtract padding to get actual difference (i.e. sizing)
pad_off = Vec2.repeat(self._padding)
orig_bb_size = (orig_dragged_point -
fixed_point).elem_abs() - pad_off * 2
target_size = (target_point - fixed_point).elem_abs() - pad_off * 2
size_ratio = target_size.elem_div(orig_bb_size)
# size too small?
if size_ratio.x < self._min_resize_ratio.x:
size_ratio = size_ratio.swapped(0, self._min_resize_ratio.x)
if size_ratio.y < self._min_resize_ratio.y:
size_ratio = size_ratio.swapped(1, self._min_resize_ratio.y)
# re-calculate target_size in case size_ratio changed
target_size = orig_bb_size.elem_mul(size_ratio)
# STEP 3 calculate new bounding_rect position and size
br_pos = Vec2(min(fixed_point.x, target_point.x), min(fixed_point.y, target_point.y))
# STEP 4 calculate and apply new node positions and sizes
# calculate and keep incremental ratio for the event arguments
inc_ratio = orig_bb_size.elem_mul(size_ratio).elem_div(rect_data[0].size)
offsets = list()
index = 0
for index, (rdata, opos, osize) in enumerate(zip(rect_data, orig_pos, orig_sizes)):
#assert opos.x >= -1e-6 and opos.y >= -1e-6
pos = (br_pos + opos.elem_mul(size_ratio) + pad_off)
# HACK rect_data is compartments + nodes. So we only append to offsets we've reached
# the nodes sectoin
if index >= len(self.compartments):
offsets.append(pos - rdata.position)
rdata.position = pos
rdata.size = osize.elem_mul(size_ratio)
# STEP 5 apply new bounding_rect position and size
self.bounding_rect.position = (br_pos + pad_off)
self.bounding_rect.size = target_size
# STEP 6 post main events
if len(self.nodes) != 0:
post_event(DidResizeNodesEvent(node_indices=self.node_indices, ratio=inc_ratio,
dragged=True))
post_event(DidMoveNodesEvent(node_indices=self.node_indices,
offset=offsets[len(self.compartments):], dragged=True))
if len(self.compartments) != 0:
post_event(DidResizeCompartmentsEvent(
compartment_indices=self.comp_indices, ratio=inc_ratio, dragged=True))
post_event(DidMoveCompartmentsEvent(
compartment_indices=self.comp_indices,
offset=offsets[:len(self.compartments)], dragged=True))
# STEP 7 adjust peripheral node positions so that they are inside the compartment
adjusted_nodes = list()
offsets = list()
for node in self.peripheral_nodes:
assert node.comp_idx != -1
comp = self.canvas.comp_idx_map[node.comp_idx]
pos = clamp_rect_pos(node.rect, comp.rect)
if node.position != pos:
adjusted_nodes.append(node)
offsets.append(pos - node.position)
node.position = pos
if len(adjusted_nodes) != 0:
post_event(DidMoveNodesEvent(adjusted_nodes, offsets, dragged=True))
def _move(self, pos: Vec2, rect_data: List[RectData], rel_positions: List[Vec2]):
"""Helper that performs resize on the bounding box, given the logical mouse position."""
assert len(rect_data) == len(rel_positions)
assert len(rect_data) != 0
# campute tentative new positions. May need to clamp it.
self._did_move = True
new_positions = [pos + rp for rp in rel_positions]
min_x = min(p.x for p in new_positions)
min_y = min(p.y for p in new_positions)
max_x = max(p.x + r.size.x for p,
r in zip(new_positions, rect_data))
max_y = max(p.y + r.size.y for p,
r in zip(new_positions, rect_data))
offset = Vec2(0, 0)
s_bounds = self._bounds
lim_topleft = s_bounds.position
lim_botright = s_bounds.position + s_bounds.size
if min_x < lim_topleft.x:
assert max_x <= lim_botright.x
offset += Vec2(lim_topleft.x - min_x, 0)
elif max_x > lim_botright.x:
offset += Vec2(lim_botright.x - max_x, 0)
if min_y < lim_topleft.y:
assert max_y <= lim_botright.y
offset += Vec2(0, lim_topleft.y - min_y)
elif max_y > lim_botright.y:
offset += Vec2(0, lim_botright.y - max_y)
self.bounding_rect.position = (pos + offset + self._drag_rel)
# The actual amount moved by the rects
pos_offset = (new_positions[0] + offset) - rect_data[0].position
for rdata, np in zip(rect_data, new_positions):
rdata.position = (np + offset)
# Note that don't need to test if out of bounds by peripheral nodes, since all
# peripheral nodes must be inside selected compartments.
for node in self.peripheral_nodes:
node.position += pos_offset
all_nodes = self.nodes + self.peripheral_nodes
if len(all_nodes) != 0:
post_event(DidMoveNodesEvent([n.index for n in all_nodes], pos_offset, dragged=True))
if len(self.compartments) != 0:
post_event(DidMoveCompartmentsEvent(compartment_indices=[c.index for c in self.compartments],
offset=pos_offset, dragged=True))
[docs] def move_offset(self, offset: Vec2):
nodes = [n for n in self.nodes if not n.lockNode]
rect_data = cast(List[RectData], self.compartments) + cast(List[RectData], nodes)
pos = self.bounding_rect.position
rel_node_pos = [n.position - pos for n in self.nodes if not n.lockNode]
rel_comp_pos = [c.position - pos for c in self.compartments]
rel_positions = rel_comp_pos + rel_node_pos
if len(rect_data) == 0:
return
self._move(pos + offset, rect_data, rel_positions)
self._commit_move()
def _commit_move(self):
with self.controller.group_action():
for node in chain(self.nodes, self.peripheral_nodes):
self.controller.move_node(self.net_index, node.index, node.position)
for comp in self.compartments:
self.controller.move_compartment(
self.net_index, comp.index, comp.position)
if self.special_mode == SelectBox.SMode.NODES_IN_ONE:
compi = self.canvas.InWhichCompartment(self.nodes)
old_compi = self.nodes[0].comp_idx
if compi != old_compi:
for node in self.nodes:
self.controller.set_compartment_of_node(
self.net_index, node.index, compi)
post_event(DidChangeCompartmentOfNodesEvent(
node_indices=[n.index for n in self.nodes],
old_compi=old_compi,
new_compi=compi
))
post_event(DidCommitDragEvent(self))
[docs]def primitive_peninfo(color: Color, width: float, is_alias: bool):
info = wx.GraphicsPenInfo(color.to_wxcolour(), width)
if is_alias:
info = info.Style(wx.PENSTYLE_SHORT_DASH)
return info
[docs]def primitive_brush(color: Color, is_alias: bool):
# style = wx.BRUSHSTYLE_FIRST_HATCH if is_alias else wx.BRUSHSTYLE_SOLID
style = wx.BRUSHSTYLE_SOLID
brush = wx.Brush(color.to_wxcolour(), style=style)
return brush
[docs]def draw_circle_to_gc(gc: wx.GraphicsContext, box: Rect, circle: CirclePrim, is_alias: bool):
peninfo = primitive_peninfo(circle.border_color, circle.border_width, is_alias)
pen = gc.CreatePen(peninfo)
brush = gc.CreateBrush(primitive_brush(circle.fill_color, is_alias))
gc.SetPen(pen)
gc.SetBrush(brush)
path = gc.CreatePath()
path.AddEllipse(box.position.x, box.position.y, box.size.x, box.size.y)
gc.DrawPath(path)
[docs]def draw_rect_to_gc(gc: wx.GraphicsContext, box: Rect, rect: RectanglePrim, is_alias: bool):
# restrict the border width of the node to an even integer
# this is necessary for aligning the node rectangle to the selection rectangle
# why? see Rect::aligned().
box = box.aligned()
if rect.border_width == 0:
border_width = 0
else:
border_width = max(even_round(rect.border_width), 2)
pen = gc.CreatePen(primitive_peninfo(rect.border_color, border_width, is_alias))
brush = gc.CreateBrush(primitive_brush(rect.fill_color, is_alias))
gc.SetPen(pen)
gc.SetBrush(brush)
gc.DrawRoundedRectangle(box.position.x, box.position.y, box.size.x, box.size.y, rect.corner_radius)
[docs]def draw_polygon_to_gc(gc: wx.GraphicsContext, box: Rect, poly: PolygonPrim, is_alias: bool):
pen = gc.CreatePen(primitive_peninfo(poly.border_color, poly.border_width, is_alias))
brush = gc.CreateBrush(primitive_brush(poly.fill_color, is_alias))
gc.SetPen(pen)
gc.SetBrush(brush)
# offset so polygon is centered at (0.5, 0.5)
points = [p + Vec2.repeat(0.5) for p in poly.points]
transformed_pts = [box.position + p.elem_mul(box.size) for p in points]
gc.DrawLines([wx.Point2D(*p) for p in transformed_pts])
# HACK bypass Python typing warnings with Type variables
draw_fn_map: Dict[Any, Any] = {
CirclePrim: draw_circle_to_gc,
RectanglePrim: draw_rect_to_gc,
HexagonPrim: draw_polygon_to_gc,
LinePrim: draw_polygon_to_gc,
TrianglePrim: draw_polygon_to_gc
}
# def apply_transform_to_gc(gc: wx.GraphicsContext, transform: Transform):
# gc.Translate(*transform.translation)
# gc.Rotate(transform.rotation)
# gc.Scale(*transform.scale)
[docs]def draw_composite_shape(gc: wx.GraphicsContext, bounding_rect: Rect, node: Node):
shape = node.composite_shape
gc.PushState()
# gc.Translate(*(bounding_rect.position.elem_mul(bounding_rect.size)))
# gc.Scale(*bounding_rect.size)
for primitive, transform in shape.items:
gc.PushState()
# apply_transform_to_gc(gc, transform)
box = Rect(bounding_rect.position + bounding_rect.size.elem_mul(transform.translation),
bounding_rect.size.elem_mul(transform.scale))
draw_fn_map[primitive.__class__](gc, box, primitive, node.original_index != -1)
gc.PopState()
gc.PopState()
gc.PushState()
# apply_transform_to_gc(gc, node.composite_shape.text_item[1])
draw_text_to_gc(gc, bounding_rect, node.id, node.composite_shape.text_item)
gc.PopState()
def _truncate_text(gc: wx.GraphicsContext, max_width: float, text: str):
'''Truncate the given text (add ellipsis to the end) so that its width fits inside `max_width`.
Assume that we are using the currently selected font in gc.
'''
tw, th, _, _ = gc.GetFullTextExtent(text)
# very rough chopping
if tw > max_width:
text_len = int((max_width / tw) * len(text))
text = text[:text_len]
if len(text) > 4:
text = text[:-3] + '....'
else:
text = '....'
return text
[docs]def draw_text_to_gc(gc: wx.GraphicsContext, bounding_rect: Rect, full_text_string, text_item: Tuple[TextPrim, Transform]):
primitive, transform = text_item
# Maybe cache this?
fg_color = primitive.font_color.to_wxcolour()
font = wx.Font(wx.FontInfo(primitive.font_size)
.Family(primitive.font_family)
.Style(primitive.font_style)
.Weight(primitive.font_weight))
gfont = gc.CreateFont(font, fg_color)
gc.SetFont(gfont)
brush = gc.CreateBrush(wx.Brush(primitive.bg_color.to_wxcolour()))
width, height = bounding_rect.size
text_string = _truncate_text(gc, bounding_rect.size.x, full_text_string)
tw, th, _, _ = gc.GetFullTextExtent(text_string)
tw_full, th_full, _, _ = gc.GetFullTextExtent(full_text_string)
# remaining x and y
rx = width - tw
ry = height - th
# upper left corner of node
text_pos = bounding_rect.position + transform.translation.elem_mul(bounding_rect.size)
# do not truncate text if outside node
if not primitive.position == TextPosition.IN_NODE:
text_string = full_text_string
text_pos = text_pos - Vec2(tw_full + th/2, 0)
rx = th + tw_full + width
if primitive.alignment == TextAlignment.LEFT:
draw_pos = text_pos + Vec2(0, ry / 2)
elif primitive.alignment == TextAlignment.CENTER:
draw_pos = text_pos + Vec2(rx, ry) / 2
elif primitive.alignment == TextAlignment.RIGHT:
draw_pos = text_pos + Vec2(rx, ry / 2)
else:
assert False, "This should not happen"
if primitive.position == TextPosition.ABOVE:
draw_pos = draw_pos + Vec2(0, -height/2 - 2*th)
elif primitive.position == TextPosition.BELOW:
draw_pos = draw_pos + Vec2(0, height/2 + 2*th)
gc.DrawText(text_string, draw_pos.x, draw_pos.y, brush)