diff --git a/typetapper/hierarchy_graph.py b/typetapper/hierarchy_graph.py index cbb93f8..c23ae27 100644 --- a/typetapper/hierarchy_graph.py +++ b/typetapper/hierarchy_graph.py @@ -63,7 +63,7 @@ class HierarchicalGraph(RelativeAtomGraph): if adj in self.frontier and adj not in queue and predicate(adj): queue.add(adj) - def expand_by_key(self, seed: RelativeAtomOrGroup, group: RelativeAtomGroup, key: Callable[[RelativeAtom], Any]): + def expand_by_key(self, seed: RelativeAtomOrGroup, group: RelativeAtomGroup, key: Callable[[RelativeAtom], Any]) -> Dict[Any, RelativeAtomGroup]: queues = defaultdict(set) for atom in self.atoms(seed): if atom in self.frontier: @@ -73,10 +73,14 @@ class HierarchicalGraph(RelativeAtomGraph): if adj in self.frontier: queues[key(adj)].add(adj) + result = {} for keyed, queue in queues.items(): subgroup = self.create_group([], group) + result[keyed] = subgroup self.expand_while(queue, subgroup, lambda atom: key(atom) == keyed) + return result + def atoms(self, node: RelativeAtomOrGroup) -> Iterable[RelativeAtom]: if isinstance(node, RelativeAtomGroup): for child in node.children: diff --git a/typetapper/hierarchy_graph_view.py b/typetapper/hierarchy_graph_view.py index 405a6bd..f04a069 100644 --- a/typetapper/hierarchy_graph_view.py +++ b/typetapper/hierarchy_graph_view.py @@ -1,3 +1,4 @@ +from itertools import chain from typing import Tuple, Optional, Union, Dict, List, Set from collections import defaultdict from math import sqrt, sin, cos, pi @@ -34,6 +35,11 @@ BOX_BORDER_COLOR = QColor(0x60, 0x60, 0x60) BOX_BORDER_SELECTED_COLOR = QColor(0x60, 0x60, 0xff) BOX_BORDER_WIDTH = 1 BOX_BORDER_SELECTED_WIDTH = 2 +PLUS_COLOR = QColor(0x40, 0xc0, 0xb0) +PLUS_ICON_COLOR = QColor(0x50, 0xd0, 0xf0) +PLUS_HOVERED_COLOR = QColor(0x40, 0xc0, 0xc0) +PLUS_BORDER_COLOR = QColor(0x30, 0xc0, 0x50) +PLUS_ICON_FONT = QFont("default", 12) SCENE_MARGIN = 200 DPI = 72 @@ -49,12 +55,12 @@ def kind_to_color(kind: DataKind) -> QColor: raise ValueError(kind) class HierarchicalGraphView(BaseView): - def __init__(self, instance, default_docking_position, hg: HierarchicalGraph, *args, current_group: Optional[RelativeAtomGroup]=None, **kwargs): + def __init__(self, instance, default_docking_position, hg: HierarchicalGraph, faux_frontier: Set[RelativeAtomOrGroup], *args, current_group: Optional[RelativeAtomGroup]=None, **kwargs): super().__init__("typetapper", instance, default_docking_position, *args, **kwargs) self.base_caption = "TypeTapper" layout = QHBoxLayout(self) - self.graph = HierarchicalGraphWidget(self, instance, hg, current_group) + self.graph = HierarchicalGraphWidget(self, instance, hg, current_group, faux_frontier) layout.addWidget(self.graph) self.setLayout(layout) @@ -65,34 +71,40 @@ class ExternNode: RelativeAtomOrGroupOrExtern = Union[RelativeAtomOrGroup, ExternNode] class HierarchicalGraphWidget(QZoomableDraggableGraphicsView): - def __init__(self, parent, instance: Instance, hg: HierarchicalGraph, current_group: Optional[RelativeAtomGroup]=None): + def __init__(self, parent, instance: Instance, hg: HierarchicalGraph, current_group: Optional[RelativeAtomGroup]=None, faux_frontier: Optional[Set[RelativeAtomOrGroup]]=None): self.hg = hg self.current_group: Union[ObjectContainer, RelativeAtomGroup] = ObjectContainer(hg.root_group if current_group is None else current_group) self.selected_nodes: Union[ObjectContainer, Set[RelativeAtomOrGroupOrExtern]] = ObjectContainer(set()) self.instance = instance - self._labels = {} + self.qnodes: List[HGNode] = [] self.qedges: List[HGArrow] = [] + self.expansion_qnodes: List[HGNode] = [] + self.expansion_qedges: List[HGArrow] = [] + self.expansion_models: List[RelativeAtomOrGroup] = [] + self.layout_prog = 'neato' self.layout_social_distancing = 200 / DPI self.layout_overlap = 'vpsc' + self._labels = {} self.ng = None self.ug = None self.ag = None self.ug_lookup: List[RelativeAtomOrGroupOrExtern] = [] self.ug_reverse: Dict[RelativeAtomOrGroupOrExtern, int] = {} - self.adding_positions = {} self._layout_lock = Lock() # ??? should this be a qt mutex? self._layout_animation = QTimer() self._layout_animation_controller = QTimer() self._mouse_is_down = False self._selection_event_occurring = False self._layout_event_occurring = False + self.faux_frontier = faux_frontier or set() super().__init__(parent) self.setScene(QGraphicsScene()) + self.scene().selectionChanged.connect(self._on_selection_changed_internal) self.current_group.am_subscribe(self._on_change_group) self.selected_nodes.am_subscribe(self._on_selection_changed) self._layout_animation.setInterval(16) @@ -121,16 +133,118 @@ class HierarchicalGraphWidget(QZoomableDraggableGraphicsView): if isinstance(node, RelativeAtom): self._labels[node] = str(node.atom) else: + self._labels[node] = 'Unknown group' funcs = {self.hg.kp.atom_to_function(atom) for atom in self.hg.atoms(node) if atom not in self.hg.frontier} if len(funcs) == 1: func = funcs.pop() - self._labels[node] = self.instance.kb.functions[func].name - else: - self._labels[node] = 'Unknown group' + if func in self.instance.kb.functions: + self._labels[node] = self.instance.kb.functions[func].name return self._labels[node] + def can_expand(self, model: RelativeAtomOrGroupOrExtern): + if model not in self.ng.nodes: + return False + return any(adj in self.faux_frontier for adj in chain(self.ng.succ[model], self.ng.pred[model])) + + def begin_expand(self, model: RelativeAtomOrGroup): + qnode = self.qnodes[self.ug_reverse[model]] + self.scene().clearSelection() + qnode.setSelected(True) + for other_qnode in self.qnodes: + other_qnode.setAcceptHoverEvents(False) + other_qnode.setAcceptedMouseButtons(Qt.MouseButton.NoButton | 0) + if qnode is other_qnode: + other_qnode.setOpacity(0.8) + else: + other_qnode.setOpacity(0.4) + for edge in self.qedges: + edge.setOpacity(0.4) + + byte_height = self._byte_height + self.expansion_models = [adj for adj in chain(self.ng.succ[model], self.ng.pred[model]) if adj in self.faux_frontier] + self.expansion_qnodes = [PropChartHG( + None, + prop=self.hg.prop(other_model), + byte_height=byte_height, + model=other_model, + hgw=self, + label=self.label(other_model) + ) for other_model in self.expansion_models] + self.expansion_qedges = [HGArrow( + None, + qnode, + other_qnode, + other_model in self.ng.succ[model], + other_model in self.ng.pred[model] + ) for other_qnode, other_model in zip(self.expansion_qnodes, self.expansion_models)] + + tmp_ag = pygraphviz.AGraph() + tmp_ag.add_node('x') + qnode.set_agraph_node_properties(tmp_ag.get_node('x')) + for idx, other_qnode in enumerate(self.expansion_qnodes): + tmp_ag.add_node(idx) + other_qnode.set_agraph_node_properties(tmp_ag.get_node(idx)) + tmp_ag.add_edge(idx, 'x', len=self.layout_social_distancing) + tmp_ag.graph_attr['overlap'] = 'false' + + tmp_ag._layout() + origin = parse_pos(tmp_ag.get_node('x').attr['pos']) + for idx, other_qnode in enumerate(self.expansion_qnodes): + self.scene().addItem(other_qnode) + other_qnode.center = parse_pos(tmp_ag.get_node(idx).attr['pos']) - origin + qnode.center + other_qnode.enter() + other_qnode.set_expandable(False) + + for qedge in self.expansion_qedges: + self.scene().addItem(qedge) + qedge.layout() + qedge.enter() + + @property + def is_expanding(self): + return bool(self.expansion_qnodes) + + def end_expand(self, accepted_qnode: Optional['PropChartHG']=None): + for qnode in self.qnodes: + qnode.setAcceptHoverEvents(True) + qnode.setAcceptedMouseButtons(Qt.MouseButton.AllButtons | 0) + qnode.setOpacity(1) + for edge in self.qedges: + edge.setOpacity(1) + + for qnode in self.expansion_qnodes: + if qnode is accepted_qnode: + model = accepted_qnode.model + self.ug_reverse[model] = len(self.ug_lookup) + self.qnodes.append(accepted_qnode) + self.ug_lookup.append(model) + else: + qnode.exit() + + for qedge in self.expansion_qedges: + if qedge.start is accepted_qnode or qedge.end is accepted_qnode: + self.qedges.append(qedge) + qedge.start.edges[qedge.end.model] = qedge + qedge.end.edges[qedge.start.model] = qedge + else: + qedge.exit() + self.expansion_qedges.clear() + self.expansion_qnodes.clear() + self.expansion_models.clear() + + if accepted_qnode: + self.faux_frontier.remove(accepted_qnode.model) + newgroups = self.hg.expand_by_key(accepted_qnode.model, self.current_group.am_obj, self.hg.kp.atom_to_function) + self.faux_frontier.update(newgroups.values()) + self._layout(0) + # private interfaces + @property + def _byte_height(self): + word_height = 40 + return word_height / self.hg.kp.kb._project.arch.bytes + def _animate_layout(self, duration=500): self._layout_animation_controller.setInterval(duration) self._layout_animation_controller.start() @@ -138,13 +252,14 @@ class HierarchicalGraphWidget(QZoomableDraggableGraphicsView): def _layout(self, maxiter=1): old_nodes = dict(zip(self.ug_lookup, self.qnodes)) - - word_height = 40 - byte_height = word_height / self.hg.kp.kb._project.arch.bytes + byte_height = self._byte_height current_group = self.current_group.am_obj self.ng = self.hg.local_graph(current_group) self.ug = networkx.Graph(self.ng) + for model in list(self.ug.nodes): + if model in self.faux_frontier: + self.ug.remove_node(model) for edge in self.ug.edges: del self.ug.edges[edge]['prev'] del self.ug.edges[edge]['next'] @@ -185,12 +300,12 @@ class HierarchicalGraphWidget(QZoomableDraggableGraphicsView): label=self.label(node) ) entering.add(qnode) - pos = self.adding_positions.get(node, None) - if pos is None: - pos = rand_point(self.scene().itemsBoundingRect(), i) - qnode.center = pos + qnode.center = rand_point(ibr, i) self.qnodes.append(qnode) + for model, qnode in zip(self.ug_lookup, self.qnodes): + qnode.set_expandable(self.can_expand(model)) + for u, v in self.ug.edges: lu = self.ug_lookup[u] lv = self.ug_lookup[v] @@ -229,6 +344,8 @@ class HierarchicalGraphWidget(QZoomableDraggableGraphicsView): self._iterate_layout(maxiter) def _iterate_layout(self, maxiter=1): + if not maxiter: + return if self._layout_lock.acquire(blocking=False): self._layout_event_occurring = True try: @@ -236,7 +353,7 @@ class HierarchicalGraphWidget(QZoomableDraggableGraphicsView): idx = int(node) qnode = self.qnodes[idx] qnode.set_agraph_node_properties(node) - node.attr['pos'] = f'{qnode.x()},{-qnode.y()}' + node.attr['pos'] = render_pos(qnode.center) node.attr['pin'] = str(qnode.pinned).lower() for edge in self.ag.edges_iter(): edge.attr['len'] = self.layout_social_distancing @@ -249,8 +366,7 @@ class HierarchicalGraphWidget(QZoomableDraggableGraphicsView): for node in self.ag.nodes_iter(): idx = int(node) chart = self.qnodes[idx] - posx, posy = node.attr['pos'].split(',') - newpos = QPointF(float(posx), -float(posy)) + newpos = parse_pos(node.attr['pos']) if not chart.pinned: chart.center = newpos @@ -275,7 +391,7 @@ class HierarchicalGraphWidget(QZoomableDraggableGraphicsView): # qt overrides def keyPressEvent(self, event): - if event.key() == Qt.Key_Z: + if event.key() == Qt.Key_Z and not self.is_expanding: self._layout_animation.start() event.accept() super().keyPressEvent(event) @@ -319,6 +435,23 @@ class HierarchicalGraphWidget(QZoomableDraggableGraphicsView): qnode.setSelected(True) self._selection_event_occurring = False + def _on_selection_changed_internal(self): + if self.is_expanding: + items = self.scene().selectedItems() + if not items: + self.end_expand() + elif len(items) == 1: + qnode = items[0] + assert isinstance(qnode, PropChartHG) + if qnode.model not in self.ug_reverse: + self.end_expand(qnode) + else: + l.error("How'd you select more than one item during expansion") + elif not self._selection_event_occurring: + self.selected_nodes.am_obj = {model for model, qnode in zip(self.ug_lookup, self.qnodes) if qnode.isSelected()} + self.selected_nodes.am_event(src='qt') + + class AnimatableItem(QGraphicsItem): def __init__(self, *args, **kwargs): self.timeline: Optional[QTimeLine] = None @@ -352,7 +485,7 @@ class HGNode(AnimatableItem): for edge in self.edges.values(): edge.orient(self, away=True) edge.exit(duration) - edge.start.edges.pop(self.model) + edge.start.edges.pop(self.model, None) self._start_animation(self._animate_exit, duration, self._finish_exit) def enter(self, duration=250): @@ -364,6 +497,9 @@ class HGNode(AnimatableItem): def pinned(self): return self.isSelected() and self.hgw._mouse_is_down + def set_expandable(self, expandable: bool): + pass + @property def center(self): raise NotImplementedError @@ -394,15 +530,7 @@ class HGNode(AnimatableItem): # qt overrides def itemChange(self, change, value): - if change == QGraphicsItem.ItemSelectedHasChanged: - if not self.hgw._selection_event_occurring: - if value: - self.hgw.selected_nodes.add(self.model) - else: - self.hgw.selected_nodes.discard(self.model) - - self.hgw.selected_nodes.am_event(src='qt') - elif change == QGraphicsItem.ItemPositionHasChanged: + if change == QGraphicsItem.ItemPositionHasChanged: if not self.hgw._layout_event_occurring: for edge in self.edges.values(): edge.layout() @@ -496,6 +624,7 @@ class PropChartHG(HGNode, PropChart): def __init__(self, *args, label: str, **kwargs): self.label = label self.object_label = None + self.object_btn: Optional[PlusButton] = None super().__init__(*args, **kwargs) @property @@ -527,9 +656,20 @@ class PropChartHG(HGNode, PropChart): self.object_label.setPos(QPointF(self.margin_x, self.margin_y)) self.object_label.setFont(TEXT_FONT) self.objects.append(self.object_label) + self.object_btn = PlusButton(self, self.begin_expand, 16) + self.object_btn.setPos(self.width - 18, 2) self.setTransformOriginPoint(self.center) + def set_expandable(self, expandable: bool): + self.object_btn.setVisible(expandable) + + def begin_expand(self): + if self.timeline.state() == QTimeLine.NotRunning: + # horrifying + QTimer.singleShot(10, lambda: self.hgw.begin_expand(self.model)) + return True + def paint(self, painter, option, widget=...): if self.isSelected(): self.object_bg.setPen(QPen(BOX_BORDER_SELECTED_COLOR, BOX_BORDER_SELECTED_WIDTH)) @@ -612,10 +752,15 @@ class HGExtern(HGNode): return QRectF(-self.radius, -self.radius, self.radius*2, self.radius*2) def shape(self) -> QPainterPath: - return self.object_bg.shape().translated(-self.radius, -self.radius) + result = QPainterPath() + result.addEllipse(0, 0, self.radius*2, self.radius*2) + return result def paint(self, painter, option, widget=...): - pass + if self.isSelected(): + self.object_bg.setPen(QPen(BOX_BORDER_SELECTED_COLOR, BOX_BORDER_SELECTED_WIDTH)) + else: + self.object_bg.setPen(QPen(BOX_BORDER_COLOR, BOX_BORDER_WIDTH)) class HGArrow(AnimatableItem): def __init__(self, parent, start: HGNode, end: HGNode, toward_start: bool, toward_end: bool, stroke=3., arrow_size=8.): @@ -727,6 +872,40 @@ class HGArrow(AnimatableItem): path.closeSubpath() return path +class PlusButton(QGraphicsItem): + def __init__(self, parent, callback, size): + self.callback = callback + self.size = size + super().__init__(parent) + + self.object_bg = QGraphicsRectItem(0, 0, size, size, self) + self.object_bg.setBrush(PLUS_COLOR) + self.object_bg.setPen(QPen(PLUS_BORDER_COLOR, BOX_BORDER_WIDTH)) + self.object_icon = QGraphicsSimpleTextItem("+", self) + self.object_icon.setFont(PLUS_ICON_FONT) + self.object_icon.setBrush(PLUS_ICON_COLOR) + self.object_icon.setPos(self.object_bg.boundingRect().center() - self.object_icon.boundingRect().center()) + self.setAcceptHoverEvents(True) + + def paint(self, painter, option, widget=...): + pass + + def boundingRect(self): + return QRectF(0, 0, self.size, self.size) + + def mousePressEvent(self, event): + if self.callback(): + event.ignore() + super().mousePressEvent(event) + + def hoverEnterEvent(self, event): + self.object_bg.setBrush(PLUS_HOVERED_COLOR) + super().hoverEnterEvent(event) + + def hoverLeaveEvent(self, event): + self.object_bg.setBrush(PLUS_COLOR) + super().hoverLeaveEvent(event) + def arrowhead_vectors(start: QPointF, end: QPointF, stem_width: float, flare_width: float) -> Tuple[QPointF, QPointF, QPointF, QPointF, QPointF, QPointF]: direction = end - start norm = normalized(direction) @@ -764,3 +943,10 @@ def rand_point(range: QRectF, seed=None) -> QPointF: x = r.random() y = r.random() return QPointF(range.width() * x + range.x(), range.height() * y + range.y()) + +def parse_pos(pos: str) -> QPointF: + posx, posy = pos.split(',') + return QPointF(float(posx), -float(posy)) + +def render_pos(pt: QPointF) -> str: + return f'{pt.x()},{-pt.y()}' diff --git a/typetapper/plugin.py b/typetapper/plugin.py index 8a86ee9..340a461 100644 --- a/typetapper/plugin.py +++ b/typetapper/plugin.py @@ -44,8 +44,8 @@ class TypeTapper(BasePlugin): func = self.kp.atom_to_function(node) func_group = hg.create_group(start_relatoms, hg.root_group) hg.expand_while(start_relatoms, func_group, lambda atom: self.kp.atom_to_function(atom) == func) - hg.expand_by_key(func_group, hg.root_group, self.kp.atom_to_function) + newgroups = hg.expand_by_key(func_group, hg.root_group, self.kp.atom_to_function) - view = HierarchicalGraphView(self.workspace.main_instance, "center", hg) + view = HierarchicalGraphView(self.workspace.main_instance, "center", hg, set(newgroups.values())) self.workspace.add_view(view) self.workspace.raise_view(view)