diff options
Diffstat (limited to 'grc/gui/canvas')
-rw-r--r-- | grc/gui/canvas/__init__.py | 22 | ||||
-rw-r--r-- | grc/gui/canvas/block.py | 398 | ||||
-rw-r--r-- | grc/gui/canvas/colors.py | 78 | ||||
-rw-r--r-- | grc/gui/canvas/connection.py | 254 | ||||
-rw-r--r-- | grc/gui/canvas/drawable.py | 183 | ||||
-rw-r--r-- | grc/gui/canvas/flowgraph.py | 785 | ||||
-rw-r--r-- | grc/gui/canvas/param.py | 162 | ||||
-rw-r--r-- | grc/gui/canvas/port.py | 225 |
8 files changed, 2107 insertions, 0 deletions
diff --git a/grc/gui/canvas/__init__.py b/grc/gui/canvas/__init__.py new file mode 100644 index 0000000000..f90d10c4e6 --- /dev/null +++ b/grc/gui/canvas/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# GNU Radio Companion is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# GNU Radio Companion is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + +from .block import Block +from .connection import Connection +from .flowgraph import FlowGraph +from .param import Param +from .port import Port diff --git a/grc/gui/canvas/block.py b/grc/gui/canvas/block.py new file mode 100644 index 0000000000..d336bc139a --- /dev/null +++ b/grc/gui/canvas/block.py @@ -0,0 +1,398 @@ +""" +Copyright 2007, 2008, 2009 Free Software Foundation, Inc. +This file is part of GNU Radio + +GNU Radio Companion is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +GNU Radio Companion is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +""" + +from __future__ import absolute_import, division + +import math + +import six +from gi.repository import Gtk, Pango, PangoCairo + +from . import colors +from .drawable import Drawable +from .. import Actions, Utils, Constants +from ..Constants import ( + BLOCK_LABEL_PADDING, PORT_SPACING, PORT_SEPARATION, LABEL_SEPARATION, + PORT_BORDER_SEPARATION, BLOCK_FONT, PARAM_FONT +) +from ...core import utils +from ...core.Block import Block as CoreBlock + + +class Block(CoreBlock, Drawable): + """The graphical signal block.""" + + def __init__(self, parent, **n): + """ + Block constructor. + Add graphics related params to the block. + """ + super(self.__class__, self).__init__(parent, **n) + + self.states.update(_coordinate=(0, 0), _rotation=0) + self.width = self.height = 0 + Drawable.__init__(self) # needs the states and initial sizes + + self._surface_layouts = [ + None, # title + None, # params + ] + self._surface_layouts_offsets = 0, 0 + self._comment_layout = None + + self._area = [] + self._border_color = self._bg_color = colors.BLOCK_ENABLED_COLOR + self._font_color = list(colors.FONT_COLOR) + + @property + def coordinate(self): + """ + Get the coordinate from the position param. + + Returns: + the coordinate tuple (x, y) or (0, 0) if failure + """ + return Utils.scale(self.states['_coordinate']) + + @coordinate.setter + def coordinate(self, coor): + """ + Set the coordinate into the position param. + + Args: + coor: the coordinate tuple (x, y) + """ + coor = Utils.scale(coor, reverse=True) + if Actions.TOGGLE_SNAP_TO_GRID.get_active(): + offset_x, offset_y = (0, self.height / 2) if self.is_horizontal() else (self.height / 2, 0) + coor = ( + Utils.align_to_grid(coor[0] + offset_x) - offset_x, + Utils.align_to_grid(coor[1] + offset_y) - offset_y + ) + self.states['_coordinate'] = coor + + @property + def rotation(self): + """ + Get the rotation from the position param. + + Returns: + the rotation in degrees or 0 if failure + """ + return self.states['_rotation'] + + @rotation.setter + def rotation(self, rot): + """ + Set the rotation into the position param. + + Args: + rot: the rotation in degrees + """ + self.states['_rotation'] = rot + + def _update_colors(self): + self._bg_color = ( + colors.MISSING_BLOCK_BACKGROUND_COLOR if self.is_dummy_block else + colors.BLOCK_BYPASSED_COLOR if self.state == 'bypassed' else + colors.BLOCK_ENABLED_COLOR if self.state == 'enabled' else + colors.BLOCK_DISABLED_COLOR + ) + self._font_color[-1] = 1.0 if self.state == 'enabled' else 0.4 + self._border_color = ( + colors.MISSING_BLOCK_BORDER_COLOR if self.is_dummy_block else + colors.BORDER_COLOR_DISABLED if not self.state == 'enabled' else colors.BORDER_COLOR + ) + + def create_shapes(self): + """Update the block, parameters, and ports when a change occurs.""" + if self.is_horizontal(): + self._area = (0, 0, self.width, self.height) + elif self.is_vertical(): + self._area = (0, 0, self.height, self.width) + self.bounds_from_area(self._area) + + bussified = self.current_bus_structure['source'], self.current_bus_structure['sink'] + for ports, has_busses in zip((self.active_sources, self.active_sinks), bussified): + if not ports: + continue + port_separation = PORT_SEPARATION if not has_busses else ports[0].height + PORT_SPACING + offset = (self.height - (len(ports) - 1) * port_separation - ports[0].height) / 2 + for port in ports: + port.create_shapes() + + port.coordinate = { + 0: (+self.width, offset), + 90: (offset, -port.width), + 180: (-port.width, offset), + 270: (offset, +self.width), + }[port.connector_direction] + + offset += PORT_SEPARATION if not has_busses else port.height + PORT_SPACING + + def create_labels(self, cr=None): + """Create the labels for the signal block.""" + + # (Re-)creating layouts here, because layout.context_changed() doesn't seems to work (after zoom) + title_layout, params_layout = self._surface_layouts = [ + Gtk.DrawingArea().create_pango_layout(''), # title + Gtk.DrawingArea().create_pango_layout(''), # params + ] + + if cr: # to fix up extents after zooming + PangoCairo.update_layout(cr, title_layout) + PangoCairo.update_layout(cr, params_layout) + + title_layout.set_markup( + '<span {foreground} font_desc="{font}"><b>{name}</b></span>'.format( + foreground='foreground="red"' if not self.is_valid() else '', font=BLOCK_FONT, + name=Utils.encode(self.name) + ) + ) + title_width, title_height = title_layout.get_size() + + # update the params layout + if not self.is_dummy_block: + markups = [param.format_block_surface_markup() + for param in self.params.values() if param.get_hide() not in ('all', 'part')] + else: + markups = ['<span font_desc="{font}"><b>key: </b>{key}</span>'.format(font=PARAM_FONT, key=self.key)] + + params_layout.set_spacing(LABEL_SEPARATION * Pango.SCALE) + params_layout.set_markup('\n'.join(markups)) + params_width, params_height = params_layout.get_size() if markups else (0, 0) + + label_width = max(title_width, params_width) / Pango.SCALE + label_height = title_height / Pango.SCALE + if markups: + label_height += LABEL_SEPARATION + params_height / Pango.SCALE + + # calculate width and height needed + width = label_width + 2 * BLOCK_LABEL_PADDING + height = label_height + 2 * BLOCK_LABEL_PADDING + + self._update_colors() + self.create_port_labels() + + def get_min_height_for_ports(ports): + min_height = 2 * PORT_BORDER_SEPARATION + len(ports) * PORT_SEPARATION + if ports: + min_height -= ports[-1].height + return min_height + + height = max(height, + get_min_height_for_ports(self.active_sinks), + get_min_height_for_ports(self.active_sources)) + + def get_min_height_for_bus_ports(ports): + return 2 * PORT_BORDER_SEPARATION + sum( + port.height + PORT_SPACING for port in ports if port.get_type() == 'bus' + ) - PORT_SPACING + + if self.current_bus_structure['sink']: + height = max(height, get_min_height_for_bus_ports(self.active_sinks)) + if self.current_bus_structure['source']: + height = max(height, get_min_height_for_bus_ports(self.active_sources)) + + self.width, self.height = width, height = Utils.align_to_grid((width, height)) + + self._surface_layouts_offsets = [ + (0, (height - label_height) / 2.0), + (0, (height - label_height) / 2.0 + LABEL_SEPARATION + title_height / Pango.SCALE) + ] + + title_layout.set_width(width * Pango.SCALE) + title_layout.set_alignment(Pango.Alignment.CENTER) + params_layout.set_indent((width - label_width) / 2.0 * Pango.SCALE) + + self.create_comment_layout() + + def create_port_labels(self): + for ports in (self.active_sinks, self.active_sources): + max_width = 0 + for port in ports: + port.create_labels() + max_width = max(max_width, port.width_with_label) + for port in ports: + port.width = max_width + + def create_comment_layout(self): + markups = [] + + # Show the flow graph complexity on the top block if enabled + if Actions.TOGGLE_SHOW_FLOWGRAPH_COMPLEXITY.get_active() and self.key == "options": + complexity = utils.calculate_flowgraph_complexity(self.parent) + markups.append( + '<span foreground="#444" size="medium" font_desc="{font}">' + '<b>Complexity: {num}bal</b></span>'.format(num=Utils.num_to_str(complexity), font=BLOCK_FONT) + ) + comment = self.comment # Returns None if there are no comments + if comment: + if markups: + markups.append('<span></span>') + + markups.append('<span foreground="{foreground}" font_desc="{font}">{comment}</span>'.format( + foreground='#444' if self.enabled else '#888', font=BLOCK_FONT, comment=Utils.encode(comment) + )) + if markups: + layout = self._comment_layout = Gtk.DrawingArea().create_pango_layout('') + layout.set_markup(''.join(markups)) + else: + self._comment_layout = None + + def draw(self, cr): + """ + Draw the signal block with label and inputs/outputs. + """ + border_color = colors.HIGHLIGHT_COLOR if self.highlighted else self._border_color + cr.translate(*self.coordinate) + + for port in self.active_ports(): # ports first + cr.save() + port.draw(cr) + cr.restore() + + cr.rectangle(*self._area) + cr.set_source_rgba(*self._bg_color) + cr.fill_preserve() + cr.set_source_rgba(*border_color) + cr.stroke() + + # title and params label + if self.is_vertical(): + cr.rotate(-math.pi / 2) + cr.translate(-self.width, 0) + cr.set_source_rgba(*self._font_color) + for layout, offset in zip(self._surface_layouts, self._surface_layouts_offsets): + cr.save() + cr.translate(*offset) + PangoCairo.update_layout(cr, layout) + PangoCairo.show_layout(cr, layout) + cr.restore() + + def what_is_selected(self, coor, coor_m=None): + """ + Get the element that is selected. + + Args: + coor: the (x,y) tuple + coor_m: the (x_m, y_m) tuple + + Returns: + this block, a port, or None + """ + for port in self.active_ports(): + port_selected = port.what_is_selected( + coor=[a - b for a, b in zip(coor, self.coordinate)], + coor_m=[a - b for a, b in zip(coor, self.coordinate)] if coor_m is not None else None + ) + if port_selected: + return port_selected + return Drawable.what_is_selected(self, coor, coor_m) + + def draw_comment(self, cr): + if not self._comment_layout: + return + x, y = self.coordinate + + if self.is_horizontal(): + y += self.height + BLOCK_LABEL_PADDING + else: + x += self.height + BLOCK_LABEL_PADDING + + cr.save() + cr.translate(x, y) + PangoCairo.update_layout(cr, self._comment_layout) + PangoCairo.show_layout(cr, self._comment_layout) + cr.restore() + + def get_extents(self): + extent = Drawable.get_extents(self) + x, y = self.coordinate + for port in self.active_ports(): + extent = (min_or_max(xy, offset + p_xy) for offset, min_or_max, xy, p_xy in zip( + (x, y, x, y), (min, min, max, max), extent, port.get_extents() + )) + return tuple(extent) + + ############################################## + # Controller Modify + ############################################## + def type_controller_modify(self, direction): + """ + Change the type controller. + + Args: + direction: +1 or -1 + + Returns: + true for change + """ + type_templates = ' '.join(p._type for p in self.get_children()) + type_param = None + for key, param in six.iteritems(self.params): + if not param.is_enum(): + continue + # Priority to the type controller + if param.key in type_templates: + type_param = param + break + # Use param if type param is unset + if not type_param: + type_param = param + if not type_param: + return False + + # Try to increment the enum by direction + try: + keys = list(type_param.options) + old_index = keys.index(type_param.get_value()) + new_index = (old_index + direction + len(keys)) % len(keys) + type_param.set_value(keys[new_index]) + return True + except: + return False + + def port_controller_modify(self, direction): + """ + Change the port controller. + + Args: + direction: +1 or -1 + + Returns: + true for change + """ + changed = False + # Concat the nports string from the private nports settings of all ports + nports_str = ' '.join(port._nports for port in self.get_ports()) + # Modify all params whose keys appear in the nports string + for key, param in six.iteritems(self.params): + if param.is_enum() or param.key not in nports_str: + continue + # Try to increment the port controller by direction + try: + value = param.get_evaluated() + direction + if value > 0: + param.set_value(value) + changed = True + except: + pass + return changed + diff --git a/grc/gui/canvas/colors.py b/grc/gui/canvas/colors.py new file mode 100644 index 0000000000..77d3203e78 --- /dev/null +++ b/grc/gui/canvas/colors.py @@ -0,0 +1,78 @@ +""" +Copyright 2008,2013 Free Software Foundation, Inc. +This file is part of GNU Radio + +GNU Radio Companion is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +GNU Radio Companion is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +""" + +from __future__ import absolute_import + +from gi.repository import Gtk, Gdk, cairo +# import pycairo + +from .. import Constants + + +def get_color(color_code): + color = Gdk.RGBA() + color.parse(color_code) + return color.red, color.green, color.blue, color.alpha + # chars_per_color = 2 if len(color_code) > 4 else 1 + # offsets = range(1, 3 * chars_per_color + 1, chars_per_color) + # return tuple(int(color_code[o:o + 2], 16) / 255.0 for o in offsets) + +################################################################################# +# fg colors +################################################################################# + +HIGHLIGHT_COLOR = get_color('#00FFFF') +BORDER_COLOR = get_color('#616161') +BORDER_COLOR_DISABLED = get_color('#888888') +FONT_COLOR = get_color('#000000') + +# Missing blocks stuff +MISSING_BLOCK_BACKGROUND_COLOR = get_color('#FFF2F2') +MISSING_BLOCK_BORDER_COLOR = get_color('#FF0000') + +# Flow graph color constants +FLOWGRAPH_BACKGROUND_COLOR = get_color('#FFFFFF') +COMMENT_BACKGROUND_COLOR = get_color('#F3F3F3') +FLOWGRAPH_EDGE_COLOR = COMMENT_BACKGROUND_COLOR + +# Block color constants +BLOCK_ENABLED_COLOR = get_color('#F1ECFF') +BLOCK_DISABLED_COLOR = get_color('#CCCCCC') +BLOCK_BYPASSED_COLOR = get_color('#F4FF81') + +# Connection color constants +CONNECTION_ENABLED_COLOR = get_color('#000000') +CONNECTION_DISABLED_COLOR = get_color('#BBBBBB') +CONNECTION_ERROR_COLOR = get_color('#FF0000') + +DEFAULT_DOMAIN_COLOR = get_color('#777777') + + +################################################################################# +# port colors +################################################################################# + +PORT_TYPE_TO_COLOR = {key: get_color(color) for name, key, sizeof, color in Constants.CORE_TYPES} +PORT_TYPE_TO_COLOR.update((key, get_color(color)) for key, (_, color) in Constants.ALIAS_TYPES.items()) + + +################################################################################# +# param box colors +################################################################################# + diff --git a/grc/gui/canvas/connection.py b/grc/gui/canvas/connection.py new file mode 100644 index 0000000000..ff790503ef --- /dev/null +++ b/grc/gui/canvas/connection.py @@ -0,0 +1,254 @@ +""" +Copyright 2007, 2008, 2009 Free Software Foundation, Inc. +This file is part of GNU Radio + +GNU Radio Companion is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +GNU Radio Companion is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +""" + +from __future__ import absolute_import, division + +from argparse import Namespace +from math import pi + +from . import colors +from .drawable import Drawable +from .. import Utils +from ..Constants import ( + CONNECTOR_ARROW_BASE, + CONNECTOR_ARROW_HEIGHT, + GR_MESSAGE_DOMAIN, + LINE_SELECT_SENSITIVITY, +) +from ...core.Connection import Connection as CoreConnection +from ...core.Element import nop_write + + +class Connection(CoreConnection, Drawable): + """ + A graphical connection for ports. + The connection has 2 parts, the arrow and the wire. + The coloring of the arrow and wire exposes the status of 3 states: + enabled/disabled, valid/invalid, highlighted/non-highlighted. + The wire coloring exposes the enabled and highlighted states. + The arrow coloring exposes the enabled and valid states. + """ + + def __init__(self, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + Drawable.__init__(self) + + self._line = [] + self._line_width_factor = 1.0 + self._color1 = self._color2 = None + + self._current_port_rotations = self._current_coordinates = None + + self._rel_points = None # connection coordinates relative to sink/source + self._arrow_rotation = 0.0 # rotation of the arrow in radians + self._current_cr = None # for what_is_selected() of curved line + self._line_path = None + + @nop_write + @property + def coordinate(self): + return self.source_port.connector_coordinate_absolute + + @nop_write + @property + def rotation(self): + """ + Get the 0 degree rotation. + Rotations are irrelevant in connection. + + Returns: + 0 + """ + return 0 + + def create_shapes(self): + """Pre-calculate relative coordinates.""" + source = self.source_port + sink = self.sink_port + rotate = Utils.get_rotated_coordinate + + # first two components relative to source connector, rest relative to sink connector + self._rel_points = [ + rotate((15, 0), source.rotation), # line from 0,0 to here, bezier curve start + rotate((50, 0), source.rotation), # bezier curve control point 1 + rotate((-50, 0), sink.rotation), # bezier curve control point 2 + rotate((-15, 0), sink.rotation), # bezier curve end + rotate((-CONNECTOR_ARROW_HEIGHT, 0), sink.rotation), # line to arrow head + ] + self._current_coordinates = None # triggers _make_path() + + def get_domain_color(domain_name): + domain = self.parent_platform.domains.get(domain_name, {}) + color_spec = domain.get('color') + return colors.get_color(color_spec) if color_spec else colors.DEFAULT_DOMAIN_COLOR + + if source.domain == GR_MESSAGE_DOMAIN: + self._line_width_factor = 1.0 + self._color1 = None + self._color2 = colors.CONNECTION_ENABLED_COLOR + else: + if source.domain != sink.domain: + self._line_width_factor = 2.0 + self._color1 = get_domain_color(source.domain) + self._color2 = get_domain_color(sink.domain) + + self._arrow_rotation = -sink.rotation / 180 * pi + + if not self._bounding_points: + self._make_path() # no cr set --> only sets bounding_points for extent + + def _make_path(self, cr=None): + x_pos, y_pos = self.coordinate # is source connector coordinate + # x_start, y_start = self.source_port.get_connector_coordinate() + x_end, y_end = self.sink_port.connector_coordinate_absolute + + # sink connector relative to sink connector + x_e, y_e = x_end - x_pos, y_end - y_pos + + # make rel_point all relative to source connector + p0 = 0, 0 # x_start - x_pos, y_start - y_pos + p1, p2, (dx_e1, dy_e1), (dx_e2, dy_e2), (dx_e3, dy_e3) = self._rel_points + p3 = x_e + dx_e1, y_e + dy_e1 + p4 = x_e + dx_e2, y_e + dy_e2 + p5 = x_e + dx_e3, y_e + dy_e3 + self._bounding_points = p0, p1, p4, p5 # ignores curved part =( + + if cr: + cr.move_to(*p0) + cr.line_to(*p1) + cr.curve_to(*(p2 + p3 + p4)) + cr.line_to(*p5) + self._line_path = cr.copy_path() + + def draw(self, cr): + """ + Draw the connection. + """ + self._current_cr = cr + sink = self.sink_port + source = self.source_port + + # check for changes + port_rotations = (source.rotation, sink.rotation) + if self._current_port_rotations != port_rotations: + self.create_shapes() # triggers _make_path() call below + self._current_port_rotations = port_rotations + + new_coordinates = (source.parent_block.coordinate, sink.parent_block.coordinate) + if self._current_coordinates != new_coordinates: + self._make_path(cr) + self._current_coordinates = new_coordinates + + color1, color2 = ( + None if color is None else + colors.HIGHLIGHT_COLOR if self.highlighted else + colors.CONNECTION_DISABLED_COLOR if not self.enabled else + colors.CONNECTION_ERROR_COLOR if not self.is_valid() else + color + for color in (self._color1, self._color2) + ) + + cr.translate(*self.coordinate) + cr.set_line_width(self._line_width_factor * cr.get_line_width()) + cr.new_path() + cr.append_path(self._line_path) + + arrow_pos = cr.get_current_point() + + if color1: # not a message connection + cr.set_source_rgba(*color1) + cr.stroke_preserve() + + if color1 != color2: + cr.save() + cr.set_dash([5.0, 5.0], 5.0 if color1 else 0.0) + cr.set_source_rgba(*color2) + cr.stroke() + cr.restore() + else: + cr.new_path() + + cr.move_to(*arrow_pos) + cr.set_source_rgba(*color2) + cr.rotate(self._arrow_rotation) + cr.rel_move_to(CONNECTOR_ARROW_HEIGHT, 0) + cr.rel_line_to(-CONNECTOR_ARROW_HEIGHT, -CONNECTOR_ARROW_BASE/2) + cr.rel_line_to(0, CONNECTOR_ARROW_BASE) + cr.close_path() + cr.fill() + + def what_is_selected(self, coor, coor_m=None): + """ + Returns: + self if one of the areas/lines encompasses coor, else None. + """ + if coor_m: + return Drawable.what_is_selected(self, coor, coor_m) + + x, y = [a - b for a, b in zip(coor, self.coordinate)] + + cr = self._current_cr + + if cr is None: + return + cr.save() + cr.new_path() + cr.append_path(self._line_path) + cr.set_line_width(cr.get_line_width() * LINE_SELECT_SENSITIVITY) + hit = cr.in_stroke(x, y) + cr.restore() + + if hit: + return self + + +class DummyCoreConnection(object): + def __init__(self, source_port, **kwargs): + self.parent_platform = source_port.parent_platform + self.source_port = source_port + self.sink_port = self._dummy_port = Namespace( + domain=source_port.domain, + rotation=0, + coordinate=(0, 0), + connector_coordinate_absolute=(0, 0), + connector_direction=0, + parent_block=Namespace(coordinate=(0, 0)), + ) + + self.enabled = True + self.highlighted = False, + self.is_valid = lambda: True + self.update(**kwargs) + + def update(self, coordinate=None, rotation=None, sink_port=None): + dp = self._dummy_port + self.sink_port = sink_port if sink_port else dp + if coordinate: + dp.coordinate = coordinate + dp.connector_coordinate_absolute = coordinate + dp.parent_block.coordinate = coordinate + if rotation is not None: + dp.rotation = rotation + dp.connector_direction = (180 + rotation) % 360 + + @property + def has_real_sink(self): + return self.sink_port is not self._dummy_port + +DummyConnection = Connection.make_cls_with_base(DummyCoreConnection) diff --git a/grc/gui/canvas/drawable.py b/grc/gui/canvas/drawable.py new file mode 100644 index 0000000000..d755d4418d --- /dev/null +++ b/grc/gui/canvas/drawable.py @@ -0,0 +1,183 @@ +""" +Copyright 2007, 2008, 2009, 2016 Free Software Foundation, Inc. +This file is part of GNU Radio + +GNU Radio Companion is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +GNU Radio Companion is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +""" + +from __future__ import absolute_import +from ..Constants import LINE_SELECT_SENSITIVITY + +from six.moves import zip + + +class Drawable(object): + """ + GraphicalElement is the base class for all graphical elements. + It contains an X,Y coordinate, a list of rectangular areas that the element occupies, + and methods to detect selection of those areas. + """ + + @classmethod + def make_cls_with_base(cls, super_cls): + name = super_cls.__name__ + bases = (super_cls,) + cls.__bases__[1:] + namespace = cls.__dict__.copy() + return type(name, bases, namespace) + + def __init__(self): + """ + Make a new list of rectangular areas and lines, and set the coordinate and the rotation. + """ + self.coordinate = (0, 0) + self.rotation = 0 + self.highlighted = False + + self._bounding_rects = [] + self._bounding_points = [] + + def is_horizontal(self, rotation=None): + """ + Is this element horizontal? + If rotation is None, use this element's rotation. + + Args: + rotation: the optional rotation + + Returns: + true if rotation is horizontal + """ + rotation = rotation or self.rotation + return rotation in (0, 180) + + def is_vertical(self, rotation=None): + """ + Is this element vertical? + If rotation is None, use this element's rotation. + + Args: + rotation: the optional rotation + + Returns: + true if rotation is vertical + """ + rotation = rotation or self.rotation + return rotation in (90, 270) + + def rotate(self, rotation): + """ + Rotate all of the areas by 90 degrees. + + Args: + rotation: multiple of 90 degrees + """ + self.rotation = (self.rotation + rotation) % 360 + + def move(self, delta_coor): + """ + Move the element by adding the delta_coor to the current coordinate. + + Args: + delta_coor: (delta_x,delta_y) tuple + """ + x, y = self.coordinate + dx, dy = delta_coor + self.coordinate = (x + dx, y + dy) + + def create_labels(self, cr=None): + """ + Create labels (if applicable) and call on all children. + Call this base method before creating labels in the element. + """ + + def create_shapes(self): + """ + Create shapes (if applicable) and call on all children. + Call this base method before creating shapes in the element. + """ + + def draw(self, cr): + raise NotImplementedError() + + def bounds_from_area(self, area): + x1, y1, w, h = area + x2 = x1 + w + y2 = y1 + h + self._bounding_rects = [(x1, y1, x2, y2)] + self._bounding_points = [(x1, y1), (x2, y1), (x1, y2), (x2, y2)] + + def bounds_from_line(self, line): + self._bounding_rects = rects = [] + self._bounding_points = list(line) + last_point = line[0] + for x2, y2 in line[1:]: + (x1, y1), last_point = last_point, (x2, y2) + if x1 == x2: + x1, x2 = x1 - LINE_SELECT_SENSITIVITY, x2 + LINE_SELECT_SENSITIVITY + if y2 < y1: + y1, y2 = y2, y1 + elif y1 == y2: + y1, y2 = y1 - LINE_SELECT_SENSITIVITY, y2 + LINE_SELECT_SENSITIVITY + if x2 < x1: + x1, x2 = x2, x1 + + rects.append((x1, y1, x2, y2)) + + def what_is_selected(self, coor, coor_m=None): + """ + One coordinate specified: + Is this element selected at given coordinate? + ie: is the coordinate encompassed by one of the areas or lines? + Both coordinates specified: + Is this element within the rectangular region defined by both coordinates? + ie: do any area corners or line endpoints fall within the region? + + Args: + coor: the selection coordinate, tuple x, y + coor_m: an additional selection coordinate. + + Returns: + self if one of the areas/lines encompasses coor, else None. + """ + x, y = [a - b for a, b in zip(coor, self.coordinate)] + + if not coor_m: + for x1, y1, x2, y2 in self._bounding_rects: + if x1 <= x <= x2 and y1 <= y <= y2: + return self + else: + x_m, y_m = [a - b for a, b in zip(coor_m, self.coordinate)] + if y_m < y: + y, y_m = y_m, y + if x_m < x: + x, x_m = x_m, x + + for x1, y1 in self._bounding_points: + if x <= x1 <= x_m and y <= y1 <= y_m: + return self + + def get_extents(self): + x_min, y_min = x_max, y_max = self.coordinate + x_min += min(x for x, y in self._bounding_points) + y_min += min(y for x, y in self._bounding_points) + x_max += max(x for x, y in self._bounding_points) + y_max += max(y for x, y in self._bounding_points) + return x_min, y_min, x_max, y_max + + def mouse_over(self): + pass + + def mouse_out(self): + pass diff --git a/grc/gui/canvas/flowgraph.py b/grc/gui/canvas/flowgraph.py new file mode 100644 index 0000000000..14326fd3f6 --- /dev/null +++ b/grc/gui/canvas/flowgraph.py @@ -0,0 +1,785 @@ +""" +Copyright 2007-2011, 2016q Free Software Foundation, Inc. +This file is part of GNU Radio + +GNU Radio Companion is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +GNU Radio Companion is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +""" + +from __future__ import absolute_import + +import ast +import functools +import random +from distutils.spawn import find_executable +from itertools import count + +import six +from gi.repository import GLib +from six.moves import filter + +from . import colors +from .drawable import Drawable +from .connection import DummyConnection +from .. import Actions, Constants, Utils, Bars, Dialogs +from ..external_editor import ExternalEditor +from ...core import Messages +from ...core.FlowGraph import FlowGraph as CoreFlowgraph + + +class FlowGraph(CoreFlowgraph, Drawable): + """ + FlowGraph is the data structure to store graphical signal blocks, + graphical inputs and outputs, + and the connections between inputs and outputs. + """ + + def __init__(self, parent, **kwargs): + """ + FlowGraph constructor. + Create a list for signal blocks and connections. Connect mouse handlers. + """ + super(self.__class__, self).__init__(parent, **kwargs) + Drawable.__init__(self) + self.drawing_area = None + # important vars dealing with mouse event tracking + self.element_moved = False + self.mouse_pressed = False + self.press_coor = (0, 0) + # selected + self.selected_elements = set() + self._old_selected_port = None + self._new_selected_port = None + # current mouse hover element + self.element_under_mouse = None + # context menu + self._context_menu = Bars.ContextMenu() + self.get_context_menu = lambda: self._context_menu + + self._new_connection = None + self._elements_to_draw = [] + self._external_updaters = {} + + def _get_unique_id(self, base_id=''): + """ + Get a unique id starting with the base id. + + Args: + base_id: the id starts with this and appends a count + + Returns: + a unique id + """ + block_ids = set(b.get_id() for b in self.blocks) + for index in count(): + block_id = '{}_{}'.format(base_id, index) + if block_id not in block_ids: + break + return block_id + + def install_external_editor(self, param): + target = (param.parent_block.get_id(), param.key) + + if target in self._external_updaters: + editor = self._external_updaters[target] + else: + config = self.parent_platform.config + editor = (find_executable(config.editor) or + Dialogs.choose_editor(None, config)) # todo: pass in parent + if not editor: + return + updater = functools.partial( + self.handle_external_editor_change, target=target) + editor = self._external_updaters[target] = ExternalEditor( + editor=editor, + name=target[0], value=param.get_value(), + callback=functools.partial(GLib.idle_add, updater) + ) + editor.start() + try: + editor.open_editor() + except Exception as e: + # Problem launching the editor. Need to select a new editor. + Messages.send('>>> Error opening an external editor. Please select a different editor.\n') + # Reset the editor to force the user to select a new one. + self.parent_platform.config.editor = '' + + def handle_external_editor_change(self, new_value, target): + try: + block_id, param_key = target + self.get_block(block_id).get_param(param_key).set_value(new_value) + + except (IndexError, ValueError): # block no longer exists + self._external_updaters[target].stop() + del self._external_updaters[target] + return + Actions.EXTERNAL_UPDATE() + + def add_new_block(self, key, coor=None): + """ + Add a block of the given key to this flow graph. + + Args: + key: the block key + coor: an optional coordinate or None for random + """ + id = self._get_unique_id(key) + scroll_pane = self.drawing_area.get_parent().get_parent() + # calculate the position coordinate + h_adj = scroll_pane.get_hadjustment() + v_adj = scroll_pane.get_vadjustment() + if coor is None: coor = ( + int(random.uniform(.25, .75)*h_adj.get_page_size() + h_adj.get_value()), + int(random.uniform(.25, .75)*v_adj.get_page_size() + v_adj.get_value()), + ) + # get the new block + block = self.new_block(key) + block.coordinate = coor + block.get_param('id').set_value(id) + Actions.ELEMENT_CREATE() + return id + + def make_connection(self): + """this selection and the last were ports, try to connect them""" + if self._new_connection and self._new_connection.has_real_sink: + self._old_selected_port = self._new_connection.source_port + self._new_selected_port = self._new_connection.sink_port + if self._old_selected_port and self._new_selected_port: + try: + self.connect(self._old_selected_port, self._new_selected_port) + Actions.ELEMENT_CREATE() + except Exception as e: + Messages.send_fail_connection(e) + self._old_selected_port = None + self._new_selected_port = None + return True + return False + + def update(self): + """ + Call the top level rewrite and validate. + Call the top level create labels and shapes. + """ + self.rewrite() + self.validate() + self.update_elements_to_draw() + self.create_labels() + self.create_shapes() + + def reload(self): + """ + Reload flow-graph (with updated blocks) + + Args: + page: the page to reload (None means current) + Returns: + False if some error occurred during import + """ + success = False + data = self.export_data() + if data: + self.unselect() + success = self.import_data(data) + self.update() + return success + + ########################################################################### + # Copy Paste + ########################################################################### + def copy_to_clipboard(self): + """ + Copy the selected blocks and connections into the clipboard. + + Returns: + the clipboard + """ + #get selected blocks + blocks = list(self.selected_blocks()) + if not blocks: + return None + #calc x and y min + x_min, y_min = blocks[0].coordinate + for block in blocks: + x, y = block.coordinate + x_min = min(x, x_min) + y_min = min(y, y_min) + #get connections between selected blocks + connections = list(filter( + lambda c: c.source_block in blocks and c.sink_block in blocks, + self.connections, + )) + clipboard = ( + (x_min, y_min), + [block.export_data() for block in blocks], + [connection.export_data() for connection in connections], + ) + return clipboard + + def paste_from_clipboard(self, clipboard): + """ + Paste the blocks and connections from the clipboard. + + Args: + clipboard: the nested data of blocks, connections + """ + # todo: rewrite this... + selected = set() + (x_min, y_min), blocks_n, connections_n = clipboard + old_id2block = dict() + # recalc the position + scroll_pane = self.drawing_area.get_parent().get_parent() + h_adj = scroll_pane.get_hadjustment() + v_adj = scroll_pane.get_vadjustment() + x_off = h_adj.get_value() - x_min + h_adj.get_page_size() / 4 + y_off = v_adj.get_value() - y_min + v_adj.get_page_size() / 4 + if len(self.get_elements()) <= 1: + x_off, y_off = 0, 0 + #create blocks + for block_n in blocks_n: + block_key = block_n.get('key') + if block_key == 'options': + continue + block = self.new_block(block_key) + if not block: + continue # unknown block was pasted (e.g. dummy block) + selected.add(block) + #set params + param_data = {n['key']: n['value'] for n in block_n.get('param', [])} + for key in block.states: + try: + block.states[key] = ast.literal_eval(param_data.pop(key)) + except (KeyError, SyntaxError, ValueError): + pass + if block_key == 'epy_block': + block.get_param('_io_cache').set_value(param_data.pop('_io_cache')) + block.get_param('_source_code').set_value(param_data.pop('_source_code')) + block.rewrite() # this creates the other params + for param_key, param_value in six.iteritems(param_data): + #setup id parameter + if param_key == 'id': + old_id2block[param_value] = block + #if the block id is not unique, get a new block id + if param_value in (blk.get_id() for blk in self.blocks): + param_value = self._get_unique_id(param_value) + #set value to key + block.get_param(param_key).set_value(param_value) + #move block to offset coordinate + block.move((x_off, y_off)) + #update before creating connections + self.update() + #create connections + for connection_n in connections_n: + source = old_id2block[connection_n.get('source_block_id')].get_source(connection_n.get('source_key')) + sink = old_id2block[connection_n.get('sink_block_id')].get_sink(connection_n.get('sink_key')) + self.connect(source, sink) + #set all pasted elements selected + for block in selected: + selected = selected.union(set(block.get_connections())) + self.selected_elements = set(selected) + + ########################################################################### + # Modify Selected + ########################################################################### + def type_controller_modify_selected(self, direction): + """ + Change the registered type controller for the selected signal blocks. + + Args: + direction: +1 or -1 + + Returns: + true for change + """ + return any(sb.type_controller_modify(direction) for sb in self.selected_blocks()) + + def port_controller_modify_selected(self, direction): + """ + Change port controller for the selected signal blocks. + + Args: + direction: +1 or -1 + + Returns: + true for changed + """ + return any(sb.port_controller_modify(direction) for sb in self.selected_blocks()) + + def change_state_selected(self, new_state): + """ + Enable/disable the selected blocks. + + Args: + new_state: a block state + + Returns: + true if changed + """ + changed = False + for block in self.selected_blocks(): + changed |= block.state != new_state + block.state = new_state + return changed + + def move_selected(self, delta_coordinate): + """ + Move the element and by the change in coordinates. + + Args: + delta_coordinate: the change in coordinates + """ + for selected_block in self.selected_blocks(): + selected_block.move(delta_coordinate) + self.element_moved = True + + def align_selected(self, calling_action=None): + """ + Align the selected blocks. + + Args: + calling_action: the action initiating the alignment + + Returns: + True if changed, otherwise False + """ + blocks = list(self.selected_blocks()) + if calling_action is None or not blocks: + return False + + # compute common boundary of selected objects + min_x, min_y = max_x, max_y = blocks[0].coordinate + for selected_block in blocks: + x, y = selected_block.coordinate + min_x, min_y = min(min_x, x), min(min_y, y) + x += selected_block.width + y += selected_block.height + max_x, max_y = max(max_x, x), max(max_y, y) + ctr_x, ctr_y = (max_x + min_x)/2, (max_y + min_y)/2 + + # align the blocks as requested + transform = { + Actions.BLOCK_VALIGN_TOP: lambda x, y, w, h: (x, min_y), + Actions.BLOCK_VALIGN_MIDDLE: lambda x, y, w, h: (x, ctr_y - h/2), + Actions.BLOCK_VALIGN_BOTTOM: lambda x, y, w, h: (x, max_y - h), + Actions.BLOCK_HALIGN_LEFT: lambda x, y, w, h: (min_x, y), + Actions.BLOCK_HALIGN_CENTER: lambda x, y, w, h: (ctr_x-w/2, y), + Actions.BLOCK_HALIGN_RIGHT: lambda x, y, w, h: (max_x - w, y), + }.get(calling_action, lambda *args: args) + + for selected_block in blocks: + x, y = selected_block.coordinate + w, h = selected_block.width, selected_block.height + selected_block.coordinate = transform(x, y, w, h) + + return True + + def rotate_selected(self, rotation): + """ + Rotate the selected blocks by multiples of 90 degrees. + + Args: + rotation: the rotation in degrees + + Returns: + true if changed, otherwise false. + """ + if not any(self.selected_blocks()): + return False + #initialize min and max coordinates + min_x, min_y = max_x, max_y = self.selected_block.coordinate + # rotate each selected block, and find min/max coordinate + for selected_block in self.selected_blocks(): + selected_block.rotate(rotation) + #update the min/max coordinate + x, y = selected_block.coordinate + min_x, min_y = min(min_x, x), min(min_y, y) + max_x, max_y = max(max_x, x), max(max_y, y) + #calculate center point of slected blocks + ctr_x, ctr_y = (max_x + min_x)/2, (max_y + min_y)/2 + #rotate the blocks around the center point + for selected_block in self.selected_blocks(): + x, y = selected_block.coordinate + x, y = Utils.get_rotated_coordinate((x - ctr_x, y - ctr_y), rotation) + selected_block.coordinate = (x + ctr_x, y + ctr_y) + return True + + def remove_selected(self): + """ + Remove selected elements + + Returns: + true if changed. + """ + changed = False + for selected_element in self.selected_elements: + self.remove_element(selected_element) + changed = True + return changed + + def update_selected(self): + """ + Remove deleted elements from the selected elements list. + Update highlighting so only the selected are highlighted. + """ + selected_elements = self.selected_elements + elements = self.get_elements() + # remove deleted elements + for selected in list(selected_elements): + if selected in elements: + continue + selected_elements.remove(selected) + if self._old_selected_port and self._old_selected_port.parent not in elements: + self._old_selected_port = None + if self._new_selected_port and self._new_selected_port.parent not in elements: + self._new_selected_port = None + # update highlighting + for element in elements: + element.highlighted = element in selected_elements + + ########################################################################### + # Draw stuff + ########################################################################### + + def update_elements_to_draw(self): + hide_disabled_blocks = Actions.TOGGLE_HIDE_DISABLED_BLOCKS.get_active() + hide_variables = Actions.TOGGLE_HIDE_VARIABLES.get_active() + + def draw_order(elem): + return elem.highlighted, elem.is_block, elem.enabled + + elements = sorted(self.get_elements(), key=draw_order) + del self._elements_to_draw[:] + + for element in elements: + if hide_disabled_blocks and not element.enabled: + continue # skip hidden disabled blocks and connections + if hide_variables and (element.is_variable or element.is_import): + continue # skip hidden disabled blocks and connections + self._elements_to_draw.append(element) + + def create_labels(self, cr=None): + for element in self._elements_to_draw: + element.create_labels(cr) + + def create_shapes(self): + for element in self._elements_to_draw: + element.create_shapes() + + def _drawables(self): + # todo: cache that + show_comments = Actions.TOGGLE_SHOW_BLOCK_COMMENTS.get_active() + for element in self._elements_to_draw: + if element.is_block and show_comments and element.enabled: + yield element.draw_comment + if self._new_connection is not None: + yield self._new_connection.draw + for element in self._elements_to_draw: + yield element.draw + + def draw(self, cr): + """Draw blocks connections comment and select rectangle""" + for draw_element in self._drawables(): + cr.save() + draw_element(cr) + cr.restore() + + draw_multi_select_rectangle = ( + self.mouse_pressed and + (not self.selected_elements or self.drawing_area.ctrl_mask) and + not self._new_connection + ) + if draw_multi_select_rectangle: + x1, y1 = self.press_coor + x2, y2 = self.coordinate + x, y = int(min(x1, x2)), int(min(y1, y2)) + w, h = int(abs(x1 - x2)), int(abs(y1 - y2)) + cr.set_source_rgba( + colors.HIGHLIGHT_COLOR[0], + colors.HIGHLIGHT_COLOR[1], + colors.HIGHLIGHT_COLOR[2], + 0.5, + ) + cr.rectangle(x, y, w, h) + cr.fill() + cr.rectangle(x, y, w, h) + cr.stroke() + + ########################################################################## + # selection handling + ########################################################################## + def update_selected_elements(self): + """ + Update the selected elements. + The update behavior depends on the state of the mouse button. + When the mouse button pressed the selection will change when + the control mask is set or the new selection is not in the current group. + When the mouse button is released the selection will change when + the mouse has moved and the control mask is set or the current group is empty. + Attempt to make a new connection if the old and ports are filled. + If the control mask is set, merge with the current elements. + """ + selected_elements = None + if self.mouse_pressed: + new_selections = self.what_is_selected(self.coordinate) + # update the selections if the new selection is not in the current selections + # allows us to move entire selected groups of elements + if not new_selections: + selected_elements = set() + elif self.drawing_area.ctrl_mask or self.selected_elements.isdisjoint(new_selections): + selected_elements = new_selections + + if self._old_selected_port: + self._old_selected_port.force_show_label = False + self.create_shapes() + self.drawing_area.queue_draw() + elif self._new_selected_port: + self._new_selected_port.force_show_label = True + + else: # called from a mouse release + if not self.element_moved and (not self.selected_elements or self.drawing_area.ctrl_mask) and not self._new_connection: + selected_elements = self.what_is_selected(self.coordinate, self.press_coor) + + # this selection and the last were ports, try to connect them + if self.make_connection(): + return + + # update selected elements + if selected_elements is None: + return + + # if ctrl, set the selected elements to the union - intersection of old and new + if self.drawing_area.ctrl_mask: + self.selected_elements ^= selected_elements + else: + self.selected_elements.clear() + self.selected_elements.update(selected_elements) + Actions.ELEMENT_SELECT() + + def what_is_selected(self, coor, coor_m=None): + """ + What is selected? + At the given coordinate, return the elements found to be selected. + If coor_m is unspecified, return a list of only the first element found to be selected: + Iterate though the elements backwards since top elements are at the end of the list. + If an element is selected, place it at the end of the list so that is is drawn last, + and hence on top. Update the selected port information. + + Args: + coor: the coordinate of the mouse click + coor_m: the coordinate for multi select + + Returns: + the selected blocks and connections or an empty list + """ + selected_port = None + selected = set() + # check the elements + for element in reversed(self._elements_to_draw): + selected_element = element.what_is_selected(coor, coor_m) + if not selected_element: + continue + # update the selected port information + if selected_element.is_port: + if not coor_m: + selected_port = selected_element + selected_element = selected_element.parent_block + + selected.add(selected_element) + if not coor_m: + break + + if selected_port and selected_port.is_source: + selected.remove(selected_port.parent_block) + self._new_connection = DummyConnection(selected_port, coordinate=coor) + self.drawing_area.queue_draw() + # update selected ports + if selected_port is not self._new_selected_port: + self._old_selected_port = self._new_selected_port + self._new_selected_port = selected_port + return selected + + def unselect(self): + """ + Set selected elements to an empty set. + """ + self.selected_elements.clear() + + def select_all(self): + """Select all blocks in the flow graph""" + self.selected_elements.clear() + self.selected_elements.update(self._elements_to_draw) + + def selected_blocks(self): + """ + Get a group of selected blocks. + + Returns: + sub set of blocks in this flow graph + """ + return (e for e in self.selected_elements if e.is_block) + + @property + def selected_block(self): + """ + Get the selected block when a block or port is selected. + + Returns: + a block or None + """ + return next(self.selected_blocks(), None) + + def get_selected_elements(self): + """ + Get the group of selected elements. + + Returns: + sub set of elements in this flow graph + """ + return self.selected_elements + + def get_selected_element(self): + """ + Get the selected element. + + Returns: + a block, port, or connection or None + """ + return next(iter(self.selected_elements), None) + + ########################################################################## + # Event Handlers + ########################################################################## + def handle_mouse_context_press(self, coordinate, event): + """ + The context mouse button was pressed: + If no elements were selected, perform re-selection at this coordinate. + Then, show the context menu at the mouse click location. + """ + selections = self.what_is_selected(coordinate) + if not selections.intersection(self.selected_elements): + self.coordinate = coordinate + self.mouse_pressed = True + self.update_selected_elements() + self.mouse_pressed = False + if self._new_connection: + self._new_connection = None + self.drawing_area.queue_draw() + self._context_menu.popup(None, None, None, None, event.button, event.time) + + def handle_mouse_selector_press(self, double_click, coordinate): + """ + The selector mouse button was pressed: + Find the selected element. Attempt a new connection if possible. + Open the block params window on a double click. + Update the selection state of the flow graph. + """ + self.press_coor = coordinate + self.coordinate = coordinate + self.mouse_pressed = True + if double_click: + self.unselect() + self.update_selected_elements() + + if double_click and self.selected_block: + self.mouse_pressed = False + Actions.BLOCK_PARAM_MODIFY() + + def handle_mouse_selector_release(self, coordinate): + """ + The selector mouse button was released: + Update the state, handle motion (dragging). + And update the selected flowgraph elements. + """ + self.coordinate = coordinate + self.mouse_pressed = False + if self.element_moved: + Actions.BLOCK_MOVE() + self.element_moved = False + self.update_selected_elements() + if self._new_connection: + self._new_connection = None + self.drawing_area.queue_draw() + + def handle_mouse_motion(self, coordinate): + """ + The mouse has moved, respond to mouse dragging or notify elements + Move a selected element to the new coordinate. + Auto-scroll the scroll bars at the boundaries. + """ + # to perform a movement, the mouse must be pressed + # (no longer checking pending events via Gtk.events_pending() - always true in Windows) + redraw = False + if not self.mouse_pressed or self._new_connection: + redraw = self._handle_mouse_motion_move(coordinate) + if self.mouse_pressed: + redraw = redraw or self._handle_mouse_motion_drag(coordinate) + if redraw: + self.drawing_area.queue_draw() + + def _handle_mouse_motion_move(self, coordinate): + # only continue if mouse-over stuff is enabled (just the auto-hide port label stuff for now) + if not Actions.TOGGLE_AUTO_HIDE_PORT_LABELS.get_active(): + return + redraw = False + for element in self._elements_to_draw: + over_element = element.what_is_selected(coordinate) + if not over_element: + continue + if over_element != self.element_under_mouse: # over sth new + if self.element_under_mouse: + redraw |= self.element_under_mouse.mouse_out() or False + self.element_under_mouse = over_element + redraw |= over_element.mouse_over() or False + break + else: + if self.element_under_mouse: + redraw |= self.element_under_mouse.mouse_out() or False + self.element_under_mouse = None + if redraw: + # self.create_labels() + self.create_shapes() + return redraw + + def _handle_mouse_motion_drag(self, coordinate): + redraw = False + # remove the connection if selected in drag event + if len(self.selected_elements) == 1 and self.get_selected_element().is_connection: + Actions.ELEMENT_DELETE() + redraw = True + + if self._new_connection: + e = self.element_under_mouse + if e and e.is_port and e.is_sink: + self._new_connection.update(sink_port=self.element_under_mouse) + else: + self._new_connection.update(coordinate=coordinate, rotation=0) + return True + # move the selected elements and record the new coordinate + x, y = coordinate + if not self.drawing_area.ctrl_mask: + X, Y = self.coordinate + dX, dY = int(x - X), int(y - Y) + active = Actions.TOGGLE_SNAP_TO_GRID.get_active() or self.drawing_area.mod1_mask + if not active or abs(dX) >= Constants.CANVAS_GRID_SIZE or abs(dY) >= Constants.CANVAS_GRID_SIZE: + self.move_selected((dX, dY)) + self.coordinate = (x, y) + redraw = True + return redraw + + def get_extents(self): + extent = 100000, 100000, 0, 0 + for element in self._elements_to_draw: + extent = (min_or_max(xy, e_xy) for min_or_max, xy, e_xy in zip( + (min, min, max, max), extent, element.get_extents() + )) + return tuple(extent) diff --git a/grc/gui/canvas/param.py b/grc/gui/canvas/param.py new file mode 100644 index 0000000000..2ec99e70d8 --- /dev/null +++ b/grc/gui/canvas/param.py @@ -0,0 +1,162 @@ +# Copyright 2007-2016 Free Software Foundation, Inc. +# This file is part of GNU Radio +# +# GNU Radio Companion is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# GNU Radio Companion is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + +from __future__ import absolute_import + +from .drawable import Drawable + +from .. import ParamWidgets, Utils, Constants + +from ...core.Param import Param as CoreParam + + +class Param(CoreParam): + """The graphical parameter.""" + + make_cls_with_base = classmethod(Drawable.make_cls_with_base.__func__) + + def get_input(self, *args, **kwargs): + """ + Get the graphical gtk class to represent this parameter. + An enum requires and combo parameter. + A non-enum with options gets a combined entry/combo parameter. + All others get a standard entry parameter. + + Returns: + gtk input class + """ + type_ = self.get_type() + if type_ in ('file_open', 'file_save'): + input_widget_cls = ParamWidgets.FileParam + + elif self.is_enum(): + input_widget_cls = ParamWidgets.EnumParam + + elif self.options: + input_widget_cls = ParamWidgets.EnumEntryParam + + elif type_ == '_multiline': + input_widget_cls = ParamWidgets.MultiLineEntryParam + + elif type_ == '_multiline_python_external': + input_widget_cls = ParamWidgets.PythonEditorParam + + else: + input_widget_cls = ParamWidgets.EntryParam + + return input_widget_cls(self, *args, **kwargs) + + def format_label_markup(self, have_pending_changes=False): + block = self.parent + # fixme: using non-public attribute here + has_callback = \ + hasattr(block, 'get_callbacks') and \ + any(self.key in callback for callback in block._callbacks) + + return '<span {underline} {foreground} font_desc="Sans 9">{label}</span>'.format( + underline='underline="low"' if has_callback else '', + foreground='foreground="blue"' if have_pending_changes else + 'foreground="red"' if not self.is_valid() else '', + label=Utils.encode(self.name) + ) + + def format_tooltip_text(self): + errors = self.get_error_messages() + tooltip_lines = ['Key: ' + self.key, 'Type: ' + self.get_type()] + if self.is_valid(): + value = str(self.get_evaluated()) + if len(value) > 100: + value = '{}...{}'.format(value[:50], value[-50:]) + tooltip_lines.append('Value: ' + value) + elif len(errors) == 1: + tooltip_lines.append('Error: ' + errors[0]) + elif len(errors) > 1: + tooltip_lines.append('Error:') + tooltip_lines.extend(' * ' + msg for msg in errors) + return '\n'.join(tooltip_lines) + + def pretty_print(self): + """ + Get the repr (nice string format) for this param. + + Returns: + the string representation + """ + ################################################## + # Truncate helper method + ################################################## + def _truncate(string, style=0): + max_len = max(27 - len(self.name), 3) + if len(string) > max_len: + if style < 0: # Front truncate + string = '...' + string[3-max_len:] + elif style == 0: # Center truncate + string = string[:max_len/2 - 3] + '...' + string[-max_len/2:] + elif style > 0: # Rear truncate + string = string[:max_len-3] + '...' + return string + + ################################################## + # Simple conditions + ################################################## + value = self.get_value() + if not self.is_valid(): + return _truncate(value) + if value in self.options: + return self.options_names[self.options.index(value)] + + ################################################## + # Split up formatting by type + ################################################## + # Default center truncate + truncate = 0 + e = self.get_evaluated() + t = self.get_type() + if isinstance(e, bool): + return str(e) + elif isinstance(e, Constants.COMPLEX_TYPES): + dt_str = Utils.num_to_str(e) + elif isinstance(e, Constants.VECTOR_TYPES): + # Vector types + if len(e) > 8: + # Large vectors use code + dt_str = self.get_value() + truncate = 1 + else: + # Small vectors use eval + dt_str = ', '.join(map(Utils.num_to_str, e)) + elif t in ('file_open', 'file_save'): + dt_str = self.get_value() + truncate = -1 + else: + # Other types + dt_str = str(e) + + # Done + return _truncate(dt_str, truncate) + + def format_block_surface_markup(self): + """ + Get the markup for this param. + + Returns: + a pango markup string + """ + return '<span {foreground} font_desc="{font}"><b>{label}:</b> {value}</span>'.format( + foreground='foreground="red"' if not self.is_valid() else '', font=Constants.PARAM_FONT, + label=Utils.encode(self.name), value=Utils.encode(self.pretty_print().replace('\n', ' ')) + ) diff --git a/grc/gui/canvas/port.py b/grc/gui/canvas/port.py new file mode 100644 index 0000000000..b74e4adfcc --- /dev/null +++ b/grc/gui/canvas/port.py @@ -0,0 +1,225 @@ +""" +Copyright 2007, 2008, 2009 Free Software Foundation, Inc. +This file is part of GNU Radio + +GNU Radio Companion is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +GNU Radio Companion is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +""" + +from __future__ import absolute_import, division + +import math + +from gi.repository import Gtk, PangoCairo, Pango + +from . import colors +from .drawable import Drawable +from .. import Actions, Utils, Constants +from ...core.Element import nop_write +from ...core.Port import Port as CorePort + + +class Port(CorePort, Drawable): + """The graphical port.""" + + def __init__(self, parent, direction, **n): + """ + Port constructor. + Create list of connector coordinates. + """ + super(self.__class__, self).__init__(parent, direction, **n) + Drawable.__init__(self) + self._connector_coordinate = (0, 0) + self._hovering = False + self.force_show_label = False + + self._area = [] + self._bg_color = self._border_color = 0, 0, 0, 0 + self._font_color = list(colors.FONT_COLOR) + + self._line_width_factor = 1.0 + self._label_layout_offsets = 0, 0 + + self.width_with_label = self.height = 0 + + self.label_layout = None + + @property + def width(self): + return self.width_with_label if self._show_label else Constants.PORT_LABEL_HIDDEN_WIDTH + + @width.setter + def width(self, value): + self.width_with_label = value + self.label_layout.set_width(value * Pango.SCALE) + + def _update_colors(self): + """ + Get the color that represents this port's type. + Codes differ for ports where the vec length is 1 or greater than 1. + + Returns: + a hex color code. + """ + if not self.parent_block.enabled: + self._font_color[-1] = 0.4 + color = colors.BLOCK_DISABLED_COLOR + else: + self._font_color[-1] = 1.0 + color = colors.PORT_TYPE_TO_COLOR.get(self.get_type()) or colors.PORT_TYPE_TO_COLOR.get('') + vlen = self.get_vlen() + if vlen > 1: + dark = (0, 0, 30 / 255.0, 50 / 255.0, 70 / 255.0)[min(4, vlen)] + color = tuple(max(c - dark, 0) for c in color) + self._bg_color = color + self._border_color = tuple(max(c - 0.3, 0) for c in color) + + def create_shapes(self): + """Create new areas and labels for the port.""" + if self.is_horizontal(): + self._area = (0, 0, self.width, self.height) + elif self.is_vertical(): + self._area = (0, 0, self.height, self.width) + self.bounds_from_area(self._area) + + self._connector_coordinate = { + 0: (self.width, self.height / 2), + 90: (self.height / 2, 0), + 180: (0, self.height / 2), + 270: (self.height / 2, self.width) + }[self.connector_direction] + + def create_labels(self, cr=None): + """Create the labels for the socket.""" + self.label_layout = Gtk.DrawingArea().create_pango_layout('') + self.label_layout.set_alignment(Pango.Alignment.CENTER) + + if cr: + PangoCairo.update_layout(cr, self.label_layout) + + if self.domain in (Constants.GR_MESSAGE_DOMAIN, Constants.DEFAULT_DOMAIN): + self._line_width_factor = 1.0 + else: + self._line_width_factor = 2.0 + + self._update_colors() + + layout = self.label_layout + layout.set_markup('<span font_desc="{font}">{name}</span>'.format( + name=Utils.encode(self.name), font=Constants.PORT_FONT + )) + label_width, label_height = self.label_layout.get_size() + + self.width = 2 * Constants.PORT_LABEL_PADDING + label_width / Pango.SCALE + self.height = 2 * Constants.PORT_LABEL_PADDING + label_height / Pango.SCALE + self._label_layout_offsets = [0, Constants.PORT_LABEL_PADDING] + if self.get_type() == 'bus': + self.height += Constants.PORT_EXTRA_BUS_HEIGHT + self._label_layout_offsets[1] += Constants.PORT_EXTRA_BUS_HEIGHT / 2 + self.height += self.height % 2 # uneven height + + def draw(self, cr): + """ + Draw the socket with a label. + """ + border_color = self._border_color + cr.set_line_width(self._line_width_factor * cr.get_line_width()) + cr.translate(*self.coordinate) + + cr.rectangle(*self._area) + cr.set_source_rgba(*self._bg_color) + cr.fill_preserve() + cr.set_source_rgba(*border_color) + cr.stroke() + + if not self._show_label: + return # this port is folded (no label) + + if self.is_vertical(): + cr.rotate(-math.pi / 2) + cr.translate(-self.width, 0) + cr.translate(*self._label_layout_offsets) + + cr.set_source_rgba(*self._font_color) + PangoCairo.update_layout(cr, self.label_layout) + PangoCairo.show_layout(cr, self.label_layout) + + @property + def connector_coordinate_absolute(self): + """the coordinate where connections may attach to""" + return [sum(c) for c in zip( + self._connector_coordinate, # relative to port + self.coordinate, # relative to block + self.parent_block.coordinate # abs + )] + + @property + def connector_direction(self): + """Get the direction that the socket points: 0,90,180,270.""" + if self.is_source: + return self.rotation + elif self.is_sink: + return (self.rotation + 180) % 360 + + @nop_write + @property + def rotation(self): + return self.parent_block.rotation + + def rotate(self, direction): + """ + Rotate the parent rather than self. + + Args: + direction: degrees to rotate + """ + self.parent_block.rotate(direction) + + def move(self, delta_coor): + """Move the parent rather than self.""" + self.parent_block.move(delta_coor) + + @property + def highlighted(self): + return self.parent_block.highlighted + + @highlighted.setter + def highlighted(self, value): + self.parent_block.highlighted = value + + @property + def _show_label(self): + """ + Figure out if the label should be hidden + + Returns: + true if the label should not be shown + """ + return self._hovering or self.force_show_label or not Actions.TOGGLE_AUTO_HIDE_PORT_LABELS.get_active() + + def mouse_over(self): + """ + Called from flow graph on mouse-over + """ + changed = not self._show_label + self._hovering = True + return changed + + def mouse_out(self): + """ + Called from flow graph on mouse-out + """ + label_was_shown = self._show_label + self._hovering = False + return label_was_shown != self._show_label |