diff --git a/python/box.py b/python/box.py index cb8e7085acc5745c9a40e50f31dc7926403fb5cc..5d886b005358cf4f9a66b5630794297b4113072a 100644 --- a/python/box.py +++ b/python/box.py @@ -10,17 +10,10 @@ from PyQt5.QtWidgets import QMenu from PyQt5.QtWidgets import QAction from graph_constants import BOX_TYPE from settings_manager import emit_signal_settings_changed, preferred_pen +from box_types import BACKGROUND_BOX +from box_menu import show_box_menu from verbose import verbose -BACKGROUND_BOX = 1 -TRACE_BOX = 2 -REPORT_BOX = 3 -TABLE_BOX = 4 -GRAPH_BOX = 5 -BAR_CHART_BOX = 6 -GANTT_CHART_BOX = 7 -AD_BOX = 8 - """box to contain QGraphicsItems children. Optionally draw box. Usage: 1) Crete box @@ -60,7 +53,6 @@ class Box(QGraphicsItem): self.is_boxed = gry_box["is_boxed"] if "x" in gry_box: self.setPos(QPointF(gry_box["x"], gry_box["y"])) - self.menu_actions = list() # graphicsItem mode self.setFlag(QGraphicsItem.ItemSendsGeometryChanges) @@ -173,89 +165,5 @@ class Box(QGraphicsItem): # right-click shows menu def contextMenuEvent(self, event): if event.reason() == QGraphicsSceneContextMenuEvent.Mouse: - self.show_box_menu() - - def _add_graph_box_menu_content(self, menu): - menu.addSeparator() - - # force directed - action_align_force_directed = QAction("Apply force-directed alignment", - parent=menu) - action_align_force_directed.setStatusTip("Straighten up node placement") - action_align_force_directed.triggered.connect( - self.view.align_force_directed) - menu.addAction(action_align_force_directed) - - # force directed x10 (slow) - action_align_force_directed = QAction("Apply more precise force-directed alignment (slower)", - parent=menu) - action_align_force_directed.setStatusTip("Straighten up node placement with greater precision") - action_align_force_directed.triggered.connect( - self.view.align_force_directed_x10) - menu.addAction(action_align_force_directed) - - # circular alignment - action_align_circular = QAction("Apply circular node alignment", - parent=menu) - action_align_circular.setStatusTip("Arrange graph nodes in a circle") - action_align_circular.triggered.connect(self.view.align_circular) - menu.addAction(action_align_circular) - - def _add_trace_box_menu_content(self, menu): - menu.addSeparator() - graph_main_scene = self.scene() - menu.addAction(graph_main_scene.event_menu.action_event_menu) - - # box menu - def show_box_menu(self): - menu = QMenu() - # action box border - action_set_boxed = QAction("Show border") - action_set_boxed.setCheckable(True) - action_set_boxed.setChecked(self.is_boxed) - @pyqtSlot(bool) - def _set_boxed(is_checked): - self.set_boxed(is_checked) - emit_signal_settings_changed() - action_set_boxed.triggered.connect(_set_boxed) - menu.addAction(action_set_boxed) - - # action content visibility - action_set_visible = QAction("Show component") - action_set_visible.setCheckable(True) - action_set_visible.setChecked(self.is_visible) - action_set_visible.setEnabled(self.box_type != BACKGROUND_BOX) - @pyqtSlot(bool) - def _set_visible(is_checked): - self.set_visible(is_checked) - emit_signal_settings_changed() - action_set_visible.triggered.connect(_set_visible) - menu.addAction(action_set_visible) - - # GRAPH_BOX menu content - if self.box_type == GRAPH_BOX: - self._add_graph_box_menu_content(menu) - - # TRACE_BOX menu content - if self.box_type == TRACE_BOX: - self._add_trace_box_menu_content(menu) - - # realign graph in GRAPH_BOX menu - if self.box_type == GRAPH_BOX: - @pyqtSlot() - def _realign_graph(): - self._menu_action_function() - emit_signal_settings_changed() - - # any externally added actions - for action in self.menu_actions: - menu.addAction(action) - - action_realign_graph = QAction("Apply force-directed alignment") - action_realign_graph.setStatusTip("Straighten up node placement") - action_realign_graph.triggered.connect(_realign_graph) - menu.addAction(action_realign_graph) - - # open the menu - _action = menu.exec(QCursor.pos()) + show_box_menu(self) diff --git a/python/box_menu.py b/python/box_menu.py new file mode 100644 index 0000000000000000000000000000000000000000..6b83639f518a27954050e6d5d85a8df23fc3d191 --- /dev/null +++ b/python/box_menu.py @@ -0,0 +1,101 @@ +from PyQt5.QtCore import pyqtSlot # for signal/slot support +from PyQt5.QtWidgets import QMenu, QAction +from PyQt5.QtGui import QCursor +from box_types import BACKGROUND_BOX, TRACE_BOX, GRAPH_BOX, AD_BOX +from edge_angular_placer import place_edges + +""" +Create and show a box_type-specific box menu. +""" + +def _add_generic_menu_content(box, menu): + + # action box border + action_set_boxed = QAction("Show border", menu) + action_set_boxed.setCheckable(True) + action_set_boxed.setChecked(box.is_boxed) + @pyqtSlot(bool) + def _set_boxed(is_checked): + box.set_boxed(is_checked) + emit_signal_settings_changed() + action_set_boxed.triggered.connect(_set_boxed) + menu.addAction(action_set_boxed) + + # action content visibility + action_set_visible = QAction("Show component", menu) + action_set_visible.setCheckable(True) + action_set_visible.setChecked(box.is_visible) + action_set_visible.setEnabled(box.box_type != BACKGROUND_BOX) + @pyqtSlot(bool) + def _set_visible(is_checked): + box.set_visible(is_checked) + emit_signal_settings_changed() + action_set_visible.triggered.connect(_set_visible) + menu.addAction(action_set_visible) + +def _add_graph_menu_content(box, menu): + menu.addSeparator() + + # force directed + action_align_force_directed = QAction("Apply force-directed alignment", + menu) + action_align_force_directed.setStatusTip("Straighten up node placement") + action_align_force_directed.triggered.connect( + box.view.align_force_directed) + menu.addAction(action_align_force_directed) + + # force directed x10 (slow) + action_align_force_directed = QAction("Apply more precise " + "force-directed alignment (slower)", menu) + action_align_force_directed.setStatusTip("Straighten up node " + "placement with greater precision") + action_align_force_directed.triggered.connect( + box.view.align_force_directed_x10) + menu.addAction(action_align_force_directed) + + # circular alignment + action_align_circular = QAction("Apply circular node alignment", menu) + action_align_circular.setStatusTip("Arrange graph nodes in a circle") + action_align_circular.triggered.connect(box.view.align_circular) + menu.addAction(action_align_circular) + +def _add_trace_menu_content(box, menu): + menu.addSeparator() + graph_main_scene = box.scene() + menu.addAction(graph_main_scene.event_menu.action_event_menu) + +def _add_activity_diagram_menu_content(box, menu): + menu.addSeparator() + + # place edges + action_place_edges = QAction("Reset edge placement", menu) + action_place_edges.setStatusTip("Reset edge placement based on " + "node positions") + @pyqtSlot(bool) + def _place_edges(): + place_edges(box.view.nodes, box.view.edges) + action_place_edges.triggered.connect(_place_edges) + menu.addAction(action_place_edges) + +def show_box_menu(box): + menu = QMenu() + print("showboxmenu", menu) + + # generic content + _add_generic_menu_content(box, menu) + + # GRAPH_BOX menu content + if box.box_type == GRAPH_BOX: + _add_graph_menu_content(box, menu) + + # TRACE_BOX menu content + if box.box_type == TRACE_BOX: + _add_trace_menu_content(box, menu) + + # AD_BOX menu content + if box.box_type == AD_BOX: + _add_activity_diagram_menu_content(box, menu) + + # open the menu + _action = menu.exec(QCursor.pos()) + diff --git a/python/box_types.py b/python/box_types.py new file mode 100644 index 0000000000000000000000000000000000000000..41d1b176999bfd24a420765af4dda2bebba11ae4 --- /dev/null +++ b/python/box_types.py @@ -0,0 +1,9 @@ +"""the box menu provides varying services depending on box type.""" +BACKGROUND_BOX = 1 +TRACE_BOX = 2 +REPORT_BOX = 3 +TABLE_BOX = 4 +GRAPH_BOX = 5 +BAR_CHART_BOX = 6 +GANTT_CHART_BOX = 7 +AD_BOX = 8 diff --git a/python/edge_angular_placer.py b/python/edge_angular_placer.py index 554d9e2ddc89579f74e9e0283e664d2397cd1517..4c887e548d48cbe37bf2c6191334fe7bbcf3f353 100644 --- a/python/edge_angular_placer.py +++ b/python/edge_angular_placer.py @@ -3,7 +3,7 @@ from PyQt5.QtCore import QLineF, QPointF from settings_manager import settings from view_ad_node import START_TYPE, END_TYPE, DECISION_TYPE, BAR_TYPE _CAN_H_TYPES = {START_TYPE, END_TYPE, DECISION_TYPE} - +_DELTA_OFFSET = 10 """ Consider node rows and columns when placing edges. @@ -32,8 +32,8 @@ placement: start round pick right angle closest to destination end round pick right angle closest to destination decision diamond pick right angle closest to destination - action rectangle vertical only, top in, bottom out - bar horizontal vertical only, top in, bottom out + action rectangle vertical only, top center in, bottom center out + bar horizontal vertical only, top area in, bottom area out To get the snap point and approach to a node, provide from point and direction: snap_pint, approach = node.placer_snap_point(other_point<p>, is_to<bool>) @@ -41,36 +41,42 @@ To get the snap point and approach to a node, provide from point and direction: class OffsetTracker(): def __init__(self, nodes): - self.right_offset = settings["ad_action_width"] * 3 / 5 - self.left_offset = -self.right_offset + self.right_offset = _DELTA_OFFSET + self.left_offset = _DELTA_OFFSET - # create a set of occupied y values for each x column + # create dict of list where keys are y and values are + # a set of (x_min, x_max) tuples representing where nodes lie. + # if an edge crosses a node then we should go around it using xyx. self.occupied_positions = defaultdict(set) for node in nodes: - self.occupied_positions[node.x()].add(node.y()) - - def next_offset(self, x): - if x == 0: - # shift left - offset = self.left_offset - self.left_offset -= settings["ad_action_width"] * 1 / 5 - else: - offset = self.right_offset - self.right_offset += settings["ad_action_width"] * 1 / 5 - return offset - - # is a node with the same y in between two vertically aligned points - def y_in_between(self, p1, p2): - if p1.x() != p2.x(): - return False, 0 - column = self.occupied_positions[p1.x()] - y1, y2 = p1.y(), p2.y() - for y in column: - if (y > y1 and y < y2) or (y < y1 and y > y2): - if p1.x() == 0: - offset = self.left_offset - return True - return False + x = node.x() + half_width = node.boundingRect().width() / 2 + self.occupied_positions[node.y()].add((x-half_width, x+half_width)) + + # return a recommended x value to use for going around nodes or just x + def recommended_x(self, x, y_from, y_to): + x_new = x + y_min = min(y_from, y_to) + y_max = max(y_from, y_to) + should_adjust_left = False + should_adjust_right = False + for y, values in self.occupied_positions.items(): + if y > y_min and y < y_max: + for x_min, x_max in values: + if x >= x_min and x <= x_max: + if x <= 0: + should_adjust_left = True + x_new = min(x_new, x_min - self.left_offset) + else: + should_adjust_right = True + x_new = max(x_new, x_max + self.right_offset) + return x_new + + def increase_left_offset(self): + self.left_offset += _DELTA_OFFSET + + def increase_right_offset(self): + self.right_offset += _DELTA_OFFSET def _map_xyx(edge): edge.ep1 = QPointF(edge.ep0) @@ -98,35 +104,37 @@ def _map_yx(edge): edge.ep1.setY(edge.ep3.y()) edge.ep2.setX(edge.ep0.x()) -def _map_dx(edge, offset): +def _map_over_x(edge, offset): edge.ep1 = QPointF(edge.ep0) edge.ep2 = QPointF(edge.ep3) dx = edge.ep0.x() + offset edge.ep1.setX(dx) edge.ep2.setX(dx) -def _place_edge(edge, offset_tracker): - if edge.from_node.node_type in _CAN_H_TYPES \ - and edge.to_node.node_type in _CAN_H_TYPES \ - and offset_tracker.y_in_between( - edge.from_node.pos(), edge.to_node.pos()): - - # place with vertical offset shift for xyx connection - offset = offset_tracker.next_offset(edge.from_node.x()) - edge.ep0, _from_approach = edge.from_node.placer_snap_point( - QPointF(edge.from_node.x() + offset, edge.from_node.y()), False) - edge.ep3, _to_approach = edge.to_node.placer_snap_point( - QPointF(edge.to_node.x() + offset, edge.to_node.y()), True) - _map_dx(edge, offset) - - elif edge.from_node.node_type in BAR_TYPE \ - and edge.to_node.node_type in BAR_TYPE: - # place bar center to bar center +# place edge points so they go around something +def _place_around(edge, offset_tracker, recommended_x): + # over to recommended_x then y then return + edge.ep0, _from_approach = edge.from_node.placer_snap_point( + QPointF(recommended_x, edge.from_node.y()), False) + edge.ep3, _to_approach = edge.to_node.placer_snap_point( + QPointF(recommended_x, edge.to_node.y()), True) + _map_over_x(edge, recommended_x) + if recommended_x < 0: + offset_tracker.increase_left_offset() + else: + offset_tracker.increase_right_offset() + +# place edge points without going around anything +def _place_to(edge): + + if edge.from_node.node_type == BAR_TYPE \ + and edge.to_node.node_type == BAR_TYPE: + # bar to bar, place yxy bar center to bar center edge.ep0, _from_approach = edge.from_node.placer_snap_point( - QPointF(edge.from_node.x(), 0), False) + QPointF(edge.from_node.x(), 0), False) edge.ep3, _to_approach = edge.to_node.placer_snap_point( - QPointF(edge.to_node.x(), 0), True) + QPointF(edge.to_node.x(), 0), True) _map_yxy(edge) else: @@ -147,6 +155,81 @@ def _place_edge(edge, offset_tracker): raise RuntimeError("bad") edge.reset_appearance() +def _place_edge(edge, offset_tracker): + from_x = edge.from_node.x() + from_y = edge.from_node.y() + to_x = edge.to_node.x() + to_y = edge.to_node.y() + print(from_x, from_y, edge.from_node.boundingRect().width(), to_x, to_y, edge.to_node.boundingRect().width()) + + # bar center to bar center + if edge.from_node.node_type == BAR_TYPE \ + and edge.to_node.node_type == BAR_TYPE: + from_x = edge.from_node.x() + from_y = edge.from_node.y() + to_x = edge.to_node.x() + to_y = edge.to_node.y() + # check for crossing with respect to from_node and to_node + recommended_x = offset_tracker.recommended_x(from_x, from_y, to_y) + if recommended_x == from_x: + # no crossing, how about to_node + recommended_x = offset_tracker.recommended_x(to_x, from_y, to_y) + if recommended_x == to_x: + # still no crossing + _place_to(edge) + else: + # crossing so go over, down, back + _place_around(edge, offset_tracker, recommended_x) + + # bar to non-bar or non-bar to bar + elif edge.from_node.node_type == BAR_TYPE \ + or edge.to_node.node_type == BAR_TYPE: + from_x = edge.from_node.x() + to_x = edge.to_node.x() + if edge.from_node.node_type == BAR_TYPE: + half_width = edge.from_node.boundingRect().width() / 2 + wide_x = from_x + narrow_x = to_x + else: + half_width = edge.to_node.boundingRect().width() / 2 + wide_x = to_x + narrow_x = from_x + x_min = wide_x - half_width + x_max = wide_x + half_width + + if narrow_x >= x_min and narrow_x <= x_max: + # edge would be straight down so maybe go around + from_y = edge.from_node.y() + to_y = edge.to_node.y() + recommended_x = offset_tracker.recommended_x(narrow_x, from_y, to_y) + if recommended_x == narrow_x: + # no crossing + _place_to(edge) + else: + # crossing so go over, down, back + _place_around(edge, offset_tracker, recommended_x) + + else: + _place_to(edge) + + # non-bar to non-bar + else: + if edge.from_node.x() == edge.to_node.x(): + from_x = edge.from_node.x() + from_y = edge.from_node.y() + to_y = edge.to_node.y() + recommended_x = offset_tracker.recommended_x(from_x, from_y, to_y) + if recommended_x == from_x: + # no crossing + _place_to(edge) + else: + # crossing so go over, down, back + _place_around(edge, offset_tracker, recommended_x) + else: + _place_to(edge) + + edge.reset_appearance() + # place edge points for all edges def place_edges(nodes, edges): offset_tracker = OffsetTracker(nodes) diff --git a/python/graph_item.py b/python/graph_item.py index 5c9b8dc89d073258f24be9e10765ed09d4112ee1..060554c274a6c01959a19828600b7ad87bac0ff5 100644 --- a/python/graph_item.py +++ b/python/graph_item.py @@ -6,7 +6,8 @@ from PyQt5.QtCore import pyqtSlot from tg_to_gry import empty_graph from node import Node from edge import Edge -from box import Box, BACKGROUND_BOX +from box_types import BACKGROUND_BOX +from box import Box from view_trace import ViewTrace from view_report import ViewReport from view_table import ViewTable diff --git a/python/gry_manager.py b/python/gry_manager.py index 91dc4b704477f41543839cf50bcb43ee81870b5d..d1f598d52e9936621af2ef9fc98abe1802bd91c8 100644 --- a/python/gry_manager.py +++ b/python/gry_manager.py @@ -128,6 +128,6 @@ class GryManager(QObject): else: # great, exported. - log("Exported Gryphon Graph file %s" % gry_file_filename) + log("Exported Gryphon Graph file %s" % filename) diff --git a/python/view_ad.py b/python/view_ad.py index 288787dc3236d4e72192305cfce06fe01d3f0be4..3a7bdc67bd5d41cd8fc3624afe80257e304f7ea7 100644 --- a/python/view_ad.py +++ b/python/view_ad.py @@ -1,7 +1,8 @@ from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # for signal/slot support from PyQt5.QtCore import QPointF from settings_manager import settings -from box import Box, AD_BOX +from box_types import AD_BOX +from box import Box from view_text import ViewText from view_ad_node import ViewADNode from view_ad_edge import ViewADEdge diff --git a/python/view_bar_chart.py b/python/view_bar_chart.py index 1aa2a6b62af1274d17db45227d569e4f139ce87c..90794d1b5f616e501e74075ea1c26d113c18e124 100644 --- a/python/view_bar_chart.py +++ b/python/view_bar_chart.py @@ -1,7 +1,8 @@ from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # for signal/slot support from PyQt5.QtCore import QPointF from settings_manager import settings -from box import Box, BAR_CHART_BOX +from box_types import BAR_CHART_BOX +from box import Box from view_text import ViewText from view_legend import ViewLegend from view_bar_chart_bars import ViewBarChartBars diff --git a/python/view_gantt_chart.py b/python/view_gantt_chart.py index 53f843e863af257c0c9778bb36c146ecc7359c6b..8394623085f3fbb85079158c614503e1f5f61295 100644 --- a/python/view_gantt_chart.py +++ b/python/view_gantt_chart.py @@ -1,7 +1,8 @@ from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # for signal/slot support from PyQt5.QtCore import QPointF from settings_manager import settings -from box import Box, GANTT_CHART_BOX +from box_types import GANTT_CHART_BOX +from box import Box from view_text import ViewText from view_legend import ViewLegend from view_gantt_chart_bars import ViewGanttChartBars diff --git a/python/view_graph.py b/python/view_graph.py index 3ca7d80890d59cc816afbe473ff11d62a2f131ff..77a0f0f2f570b8e009e5f89da6ba745aa7c443e3 100644 --- a/python/view_graph.py +++ b/python/view_graph.py @@ -1,7 +1,8 @@ from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # for signal/slot support from PyQt5.QtCore import QPointF from settings_manager import settings -from box import Box, GRAPH_BOX +from box_types import GRAPH_BOX +from box import Box from view_text import ViewText from view_graph_node import ViewGraphNode from view_graph_edge import ViewGraphEdge diff --git a/python/view_report.py b/python/view_report.py index 2450c2222c650578053e902e316e9faac0f7c68b..2a1e7f9a6edde38da5c750d7aca3f76048827c66 100644 --- a/python/view_report.py +++ b/python/view_report.py @@ -1,4 +1,5 @@ -from box import Box, REPORT_BOX +from box_types import REPORT_BOX +from box import Box from view_report_report import ViewReportReport class ViewReport(): diff --git a/python/view_report_report.py b/python/view_report_report.py index a8d44cc87ab82431520994b3a49d32e8223ed454..34aa73465ea85bb5ab4d4ab07daa653f4cda70d6 100644 --- a/python/view_report_report.py +++ b/python/view_report_report.py @@ -6,6 +6,7 @@ from graph_constants import VIEW_REPORT_TYPE from font_helper import text_width, widest_text, \ HORIZONTAL_PADDING, LEFT_PADDING, cell_height from settings_manager import preferred_pen +from box_menu import show_box_menu # text looks bad in renderable so we render text in paint @@ -109,5 +110,5 @@ class ViewReportReport(QGraphicsItem): def contextMenuEvent(self, event): if event.reason() == QGraphicsSceneContextMenuEvent.Mouse: - self.report_box.show_box_menu() + show_box_menu(self.report_box) diff --git a/python/view_table.py b/python/view_table.py index d060ca16321a26efe5d698467a68ecea3dea474f..e6f5f75d0f88cf5e260dea81091ab09d89e2b413 100644 --- a/python/view_table.py +++ b/python/view_table.py @@ -1,4 +1,5 @@ -from box import Box, TABLE_BOX +from box_types import TABLE_BOX +from box import Box from view_table_table import ViewTableTable class ViewTable(): diff --git a/python/view_table_table.py b/python/view_table_table.py index 6030ecdb44713a4d37ed11692ee1e4d991161c3b..a206b80959019ee6ac9dbf70d85c205cf05bf4a3 100644 --- a/python/view_table_table.py +++ b/python/view_table_table.py @@ -2,11 +2,11 @@ from PyQt5.QtCore import QPoint, QRectF, Qt from PyQt5.QtGui import QPainterPath, QPen from PyQt5.QtWidgets import QGraphicsItem from PyQt5.QtWidgets import QGraphicsSceneContextMenuEvent -from box import Box, TABLE_BOX from graph_constants import VIEW_TABLE_TYPE from font_helper import cell_height, text_widths, column_text_widths, \ LEFT_PADDING, HORIZONTAL_PADDING from settings_manager import preferred_pen +from box_menu import show_box_menu # text looks bad in renderable so we render text in paint @@ -142,5 +142,5 @@ class ViewTableTable(QGraphicsItem): def contextMenuEvent(self, event): if event.reason() == QGraphicsSceneContextMenuEvent.Mouse: - self.table_box.show_box_menu() + show_box_menu(self.table_box) diff --git a/python/view_trace.py b/python/view_trace.py index d774e7dd35f9324e9144dc6727460c69c0a62d8f..869480bba856c1dbe72d556be86d2706b353436f 100644 --- a/python/view_trace.py +++ b/python/view_trace.py @@ -1,5 +1,6 @@ from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot # for signal/slot support -from box import Box, TRACE_BOX +from box_types import TRACE_BOX +from box import Box from node import Node from edge import Edge from trace_collapse_helpers import collapse_below, uncollapse_below, \