drag it! animate it!

This commit is contained in:
Audrey 2022-11-05 14:39:06 -07:00
parent f73d04f7a9
commit a3ccf68845
1 changed files with 396 additions and 175 deletions

View File

@ -1,9 +1,12 @@
from typing import Tuple, Optional, Union, Dict, List
from typing import Tuple, Optional, Union, Dict, List, Set
from collections import defaultdict
from math import sqrt, sin, cos, pi
from dataclasses import dataclass
import random
from threading import Lock
import logging
from PySide6.QtCore import QRectF, QPointF
from PySide6.QtCore import QRectF, QPointF, QTimeLine, QEasingCurve, QMarginsF, QTimer, Qt
from PySide6.QtGui import QColor, QFont, QBrush, QPainterPath, QPen
from PySide6.QtWidgets import QGraphicsItem, QGraphicsRectItem, QGraphicsSimpleTextItem, QHBoxLayout, QGraphicsScene, \
QGraphicsEllipseItem
@ -21,6 +24,8 @@ from .hierarchy_graph import HierarchicalGraph, RelativeAtomGroup, RelativeAtomO
from .data import Prop, DataKind
from .relative_graph import RelativeAtom
l = logging.getLogger(__name__)
TEXT_COLOR = QColor(0, 0, 0)
TEXT_FONT = QFont("default", 10)
BACKGROUND_COLOR = QColor(255, 255, 255)
@ -30,6 +35,9 @@ BOX_BORDER_SELECTED_COLOR = QColor(0x60, 0x60, 0xff)
BOX_BORDER_WIDTH = 1
BOX_BORDER_SELECTED_WIDTH = 2
SCENE_MARGIN = 200
DPI = 72
def kind_to_color(kind: DataKind) -> QColor:
if kind == DataKind.Pointer:
return QColor(0xe0, 0x10, 0x10)
@ -50,122 +58,63 @@ class HierarchicalGraphView(BaseView):
layout.addWidget(self.graph)
self.setLayout(layout)
@dataclass(frozen=True, eq=False)
@dataclass(frozen=True)
class ExternNode:
goes_out: bool
goes_in: bool
adj_to: RelativeAtomOrGroup
RelativeAtomOrGroupOrExtern = Union[RelativeAtomOrGroup, ExternNode]
class HierarchicalGraphWidget(QZoomableDraggableGraphicsView):
def __init__(self, parent, instance: Instance, hg: HierarchicalGraph, current_group: Optional[RelativeAtomGroup]=None):
self.hg = hg
self.current_group: Union[ObjectContainer, RelativeAtomGroup] = ObjectContainer(hg.root_group if current_group is None else current_group)
self.current_node: Union[ObjectContainer, RelativeAtomOrGroup, None] = ObjectContainer(None)
self.selected_nodes: Union[ObjectContainer, Set[RelativeAtomOrGroupOrExtern]] = ObjectContainer(set())
self.instance = instance
self._labels = {}
self.nodes: Dict[RelativeAtomOrGroup, PropChartHG] = {}
self.qnodes: List[HGNode] = []
self.qedges: List[HGArrow] = []
self.layout_prog = 'neato'
self.layout_social_distancing = 200 / DPI
self.layout_overlap = 'vpsc'
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
super().__init__(parent)
self.setScene(QGraphicsScene())
self._layout()
self.current_group.am_subscribe(self.on_new_group)
self.current_group.am_subscribe(self._on_change_group)
self.selected_nodes.am_subscribe(self._on_selection_changed)
self._layout_animation.setInterval(16)
self._layout_animation.setSingleShot(False)
self._layout_animation.timeout.connect(self._iterate_layout)
self._layout_animation_controller.setSingleShot(True)
self._layout_animation_controller.timeout.connect(self._layout_animation.stop)
def on_new_group(self, **kwargs):
self._layout()
self._on_change_group(src='__init__')
def navigate(self, node: RelativeAtomOrGroup):
# public interfaces
@property
def selected_node(self) -> Optional[RelativeAtomOrGroupOrExtern]:
return self.selected_nodes[0] if len(self.selected_nodes) == 1 else None
def navigate(self, node: RelativeAtomOrGroupOrExtern):
if isinstance(node, RelativeAtomGroup):
self.current_group.am_obj = node
self.current_group.am_event()
elif isinstance(node, RelativeAtom):
self.instance.workspace.jump_to(node.atom.loc.ins_addr)
else:
assert False
def select(self, node: Optional[RelativeAtomOrGroup]):
try:
self.nodes[self.current_node.am_obj].update()
except KeyError:
pass
self.current_node.am_obj = node
self.current_node.am_event()
try:
self.nodes[self.current_node.am_obj].update()
except KeyError:
pass
def _layout(self):
for item in list(self.scene().items()):
self.scene().removeItem(item)
self.nodes.clear()
word_height = 40
byte_height = word_height / self.hg.kp.kb._project.arch.bytes
current_group = self.current_group.am_obj
ng = self.hg.local_graph(current_group)
ug = networkx.Graph(ng)
for edge in ug.edges:
del ug.edges[edge]['prev']
del ug.edges[edge]['next']
enodes = []
if current_group in ug.nodes:
for adj in list(ug.adj[current_group]):
enode = ExternNode(goes_out=current_group in ng.succ[adj], goes_in=current_group in ng.pred[adj])
enodes.append(enode)
ug.remove_edge(current_group, adj)
ug.add_edge(adj, enode)
assert len(ug.adj[current_group]) == 0
ug.remove_node(current_group)
lookup = list(ug.nodes())
reverse = {x: i for i, x in enumerate(lookup)}
qnodes: List[PropNode] = [PropExtern(None) if isinstance(node, ExternNode) else PropChartHG(
None,
self.hg.prop(node),
byte_height=byte_height,
nav=node,
hgw=self,
label=self.label(node)) for node in lookup]
networkx.relabel_nodes(ug, reverse, copy=False)
ag = networkx.drawing.nx_agraph.to_agraph(ug)
for node in ag.nodes_iter():
idx = int(node)
qnodes[idx].set_agraph_node_properties(node)
ag.edge_attr['len'] = 50 / 72 + 200 / 72
ag.graph_attr['overlap'] = 'false'
ag._layout()
#ag.draw('/home/audrey/render.dot', prog='nop')
for node in ag.nodes_iter():
idx = int(node)
chart = qnodes[idx]
posx, posy = node.attr['pos'].split(',')
chart.center = QPointF(float(posx), -float(posy))
self.scene().addItem(chart)
for u, v in ug.edges:
lu = lookup[u]
lv = lookup[v]
if isinstance(lu, ExternNode):
towards_start = lu.goes_out
towards_end = lu.goes_in
elif isinstance(lv, ExternNode):
towards_start = lv.goes_in
towards_end = lv.goes_out
else:
towards_end = lv in ng.succ[lu]
towards_start = lu in ng.succ[lv]
arrow = PropArrow(None, qnodes[u], qnodes[v], towards_start, towards_end)
self.scene().addItem(arrow)
self.nodes = {chart.nav: chart for chart in qnodes if isinstance(chart, PropChartHG)}
rect = self.scene().itemsBoundingRect()
self.resetTransform()
self.setSceneRect(rect)
self.centerOn(rect.center())
def label(self, node: RelativeAtomOrGroup) -> str:
if node not in self._labels:
@ -180,6 +129,163 @@ class HierarchicalGraphWidget(QZoomableDraggableGraphicsView):
self._labels[node] = 'Unknown group'
return self._labels[node]
# private interfaces
def _animate_layout(self, duration=500):
self._layout_animation_controller.setInterval(duration)
self._layout_animation_controller.start()
self._layout_animation.start()
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
current_group = self.current_group.am_obj
self.ng = self.hg.local_graph(current_group)
self.ug = networkx.Graph(self.ng)
for edge in self.ug.edges:
del self.ug.edges[edge]['prev']
del self.ug.edges[edge]['next']
if current_group in self.ug.nodes:
for adj in list(self.ug.adj[current_group]):
enode = ExternNode(adj)
self.ug.remove_edge(current_group, adj)
self.ug.add_edge(adj, enode)
assert len(self.ug.adj[current_group]) == 0
self.ug.remove_node(current_group)
self.ug_lookup = list(self.ug.nodes())
self.ug_reverse = {x: i for i, x in enumerate(self.ug_lookup)}
networkx.relabel_nodes(self.ug, self.ug_reverse, copy=False)
self.qnodes = []
entering: Set[HGNode] = set()
ibr = self.scene().itemsBoundingRect()
if ibr.isEmpty():
ibr = QRectF(0, -1000, 1000, 1000)
for i, node in enumerate(self.ug_lookup):
if isinstance(node, ExternNode):
qnode = old_nodes.pop(node.adj_to, None)
if qnode is None:
qnode = HGExtern(None, model=node, hgw=self)
entering.add(qnode)
qnode.center = rand_point(ibr, i)
self.qnodes.append(qnode)
else:
qnode = old_nodes.pop(node, None)
if qnode is None:
qnode = PropChartHG(
None,
prop=self.hg.prop(node),
byte_height=byte_height,
model=node,
hgw=self,
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
self.qnodes.append(qnode)
for u, v in self.ug.edges:
lu = self.ug_lookup[u]
lv = self.ug_lookup[v]
qu = self.qnodes[u]
qv = self.qnodes[v]
if lu in qv.edges:
assert lv in qu.edges
else:
assert lv not in qu.edges
if isinstance(lu, ExternNode):
towards_start = current_group in self.ng.succ[lv]
towards_end = lv in self.ng.succ[current_group]
elif isinstance(lv, ExternNode):
towards_start = lu in self.ng.succ[current_group]
towards_end = current_group in self.ng.succ[lu]
else:
towards_end = lv in self.ng.succ[lu]
towards_start = lu in self.ng.succ[lv]
arrow = HGArrow(None, self.qnodes[u], self.qnodes[v], towards_start, towards_end)
qu.edges[lv] = arrow
qv.edges[lu] = arrow
self.scene().addItem(arrow)
arrow.enter()
self.qedges.append(arrow)
# the distinction here that we call enter on an arrow manually but the node calls exit on it is weird.
# but I think it works.
# it is predicated on the idea that edges are never removed unless their nodes are removed. take note!
for qnode in old_nodes.values():
qnode.exit()
for qnode in entering:
self.scene().addItem(qnode)
qnode.enter()
self.ag = networkx.drawing.nx_agraph.to_agraph(self.ug)
self._iterate_layout(maxiter)
def _iterate_layout(self, maxiter=1):
if self._layout_lock.acquire(blocking=False):
self._layout_event_occurring = True
try:
for node in self.ag.nodes_iter():
idx = int(node)
qnode = self.qnodes[idx]
qnode.set_agraph_node_properties(node)
node.attr['pos'] = f'{qnode.x()},{-qnode.y()}'
node.attr['pin'] = str(qnode.pinned).lower()
for edge in self.ag.edges_iter():
edge.attr['len'] = self.layout_social_distancing
self.ag.graph_attr['overlap'] = self.layout_overlap
self.ag.graph_attr['maxiter'] = maxiter
self.ag.graph_attr['normalize'] = 'false'
self.ag.graph_attr['inputscale'] = '0'
self.ag._layout(self.layout_prog)
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))
if not chart.pinned:
chart.center = newpos
for edge in self.qedges:
edge.layout()
rect = None
for qnode in self.qnodes:
br = qnode.boundingRect()
br.translate(qnode.x(), qnode.y())
rect = rect.united(br) if rect is not None else br
if rect is None:
rect = QRectF()
rect = rect.marginsAdded(QMarginsF(SCENE_MARGIN, SCENE_MARGIN, SCENE_MARGIN, SCENE_MARGIN))
self.setSceneRect(rect)
finally:
self._layout_event_occurring = False
self._layout_lock.release()
else:
l.warning("Layout lock conflict. Is the application lagging?")
# qt overrides
def keyPressEvent(self, event):
if event.key() == Qt.Key_Z:
self._layout_animation.start()
event.accept()
super().keyPressEvent(event)
def keyReleaseEvent(self, event):
if event.key() == Qt.Key_Z:
self._layout_animation.stop()
event.accept()
super().keyReleaseEvent(event)
def mouseDoubleClickEvent(self, event):
super().mouseDoubleClickEvent(event)
if not event.isAccepted():
@ -189,13 +295,75 @@ class HierarchicalGraphWidget(QZoomableDraggableGraphicsView):
self.current_group.am_event()
def mousePressEvent(self, event):
self._mouse_is_down = True
super().mousePressEvent(event)
item = self.itemAt(event.pos())
if item is None:
event.accept()
self.select(None)
class PropNode(QGraphicsItem):
def mouseReleaseEvent(self, event):
self._mouse_is_down = False
super().mouseReleaseEvent(event)
# objectcontainer event handlers
def _on_change_group(self, **kwargs):
self._layout(600)
self.resetTransform()
self.centerOn(self.sceneRect().center())
def _on_selection_changed(self, src=None, **kwargs):
if src == 'qt':
return
self._selection_event_occurring = True
self.scene().clearSelection()
for node in self.selected_nodes:
qnode = self.qnodes[self.ug_reverse[node]]
qnode.setSelected(True)
self._selection_event_occurring = False
class AnimatableItem(QGraphicsItem):
def __init__(self, *args, **kwargs):
self.timeline: Optional[QTimeLine] = None
super().__init__(*args, **kwargs)
def _start_animation(self, animation, duration, finished=None, ease=QEasingCurve.Linear):
if self.timeline is not None:
self.timeline.stop()
self.timeline = QTimeLine(duration, None)
self.timeline.setEasingCurve(ease)
self.timeline.valueChanged.connect(animation)
if finished is not None:
self.timeline.finished.connect(finished)
self.timeline.start()
animation(0.)
class HGNode(AnimatableItem):
def __init__(self, *args, model: RelativeAtomOrGroupOrExtern, hgw: HierarchicalGraphWidget, **kwargs):
self.model = model
self.edges: Dict[RelativeAtomOrGroupOrExtern, HGArrow] = {}
self.hgw = hgw
super().__init__(*args, **kwargs)
self.setFlag(QGraphicsItem.ItemIsSelectable)
self.setFlag(QGraphicsItem.ItemIsMovable)
self.setFlag(QGraphicsItem.ItemSendsGeometryChanges)
# public interfaces
def exit(self, duration=250):
for edge in self.edges.values():
edge.orient(self, away=True)
edge.exit(duration)
edge.start.edges.pop(self.model)
self._start_animation(self._animate_exit, duration, self._finish_exit)
def enter(self, duration=250):
for edge in self.edges.values():
edge.orient(self, away=True)
self._start_animation(self._animate_enter, duration)
@property
def pinned(self):
return self.isSelected() and self.hgw._mouse_is_down
@property
def center(self):
raise NotImplementedError
@ -210,9 +378,45 @@ class PropNode(QGraphicsItem):
def set_agraph_node_properties(self, node: pygraphviz.Node):
raise NotImplementedError
# private interfaces
class PropChart(PropNode):
def __init__(self, parent, prop: Prop, max_width=200., default_unit=10., byte_height=10., margin_x=15., margin_y=5., padding_left=5.):
def _animate_enter(self, value):
self.setOpacity(value)
self.setScale(value)
def _animate_exit(self, value):
self.setOpacity(1 - value)
self.setScale(1 - value)
def _finish_exit(self):
self.scene().removeItem(self)
# 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 not self.hgw._layout_event_occurring:
for edge in self.edges.values():
edge.layout()
return super().itemChange(change, value)
def mouseDoubleClickEvent(self, event):
event.accept()
self.hgw.navigate(self.model)
super().mouseDoubleClickEvent(event)
class PropChart(QGraphicsItem):
def __init__(self, *args, prop: Prop, max_width=200., default_unit=10., byte_height=10., margin_x=15., margin_y=5., padding_left=5., **kwargs):
self.prop = prop
self.max_width = max_width
self.default_unit = default_unit
@ -226,60 +430,16 @@ class PropChart(PropNode):
self.objects = []
self.object_bg: Optional[QGraphicsRectItem] = None
super().__init__(parent)
super().__init__(*args, **kwargs)
self._layout_marks()
def set_agraph_node_properties(self, node: pygraphviz.Node):
node.attr['width'] = self.width / 72
node.attr['height'] = self.height / 72
node.attr['shape'] = 'box'
def clip_edge(self, extent: QPointF) -> QPointF:
center_start = self.center
center_end = extent
delta_x = center_end.x() - center_start.x()
delta_y = center_end.y() - center_start.y()
slope = delta_y / delta_x if delta_x else float('inf')
start_corner_slope = self.height / self.width
start_horiz = start_corner_slope > slope > -start_corner_slope
if start_horiz:
start_right = center_end.x() > center_start.x()
if start_right:
start_ex = self.width / 2
else:
start_ex = -self.width / 2
start_ey = start_ex * slope
else:
start_bottom = center_end.y() > center_start.y()
if start_bottom:
start_ey = self.height / 2
else:
start_ey = -self.height / 2
start_ex = start_ey / slope
return QPointF(center_start.x() + start_ex, center_start.y() + start_ey)
def boundingRect(self):
return QRectF(0, 0, self.width, self.height)
def paint(self, painter, option, widget=...):
pass
@property
def center(self) -> QPointF:
pos = self.pos()
pos.setX(pos.x() + self.width / 2)
pos.setY(pos.y() + self.height / 2)
return pos
@center.setter
def center(self, v: QPointF):
self.setX(v.x() - self.width / 2)
self.setY(v.y() - self.height / 2)
def _layout_marks(self):
data = [(offset, kind, size, count) for offset, v1 in self.prop.struct_data.items() for size, v2 in v1.items() for kind, count in v2.items()]
data.sort()
@ -332,21 +492,23 @@ class PropChart(PropNode):
tick.setPos(QPointF(self.margin_x - tick.boundingRect().width(), self.margin_y + ypos * self.byte_height))
self.objects.append(tick)
class PropChartHG(PropChart):
def __init__(self, *args, nav: RelativeAtomOrGroup, hgw: HierarchicalGraphWidget, label: str, **kwargs):
self.nav = nav
self.hgw = hgw
class PropChartHG(HGNode, PropChart):
def __init__(self, *args, label: str, **kwargs):
self.label = label
self.object_label = None
super().__init__(*args, **kwargs)
def mouseDoubleClickEvent(self, event):
event.accept()
self.hgw.navigate(self.nav)
@property
def center(self) -> QPointF:
pos = self.pos()
pos.setX(pos.x() + self.width / 2)
pos.setY(pos.y() + self.height / 2)
return pos
def mousePressEvent(self, event):
event.accept()
self.hgw.select(self.nav)
@center.setter
def center(self, v: QPointF):
self.setX(v.x() - self.width / 2)
self.setY(v.y() - self.height / 2)
def _layout_marks(self):
lbl_height = 20
@ -366,21 +528,55 @@ class PropChartHG(PropChart):
self.object_label.setFont(TEXT_FONT)
self.objects.append(self.object_label)
self.setTransformOriginPoint(self.center)
def paint(self, painter, option, widget=...):
if self.nav is self.hgw.current_node.am_obj:
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))
super().paint(painter, option, widget)
class PropExtern(PropNode):
def __init__(self, parent, radius=28.):
def clip_edge(self, extent: QPointF) -> QPointF:
center_start = self.center
center_end = extent
delta_x = center_end.x() - center_start.x()
delta_y = center_end.y() - center_start.y()
slope = delta_y / delta_x if delta_x else float('inf')
start_corner_slope = self.height / self.width
start_horiz = start_corner_slope > slope > -start_corner_slope
if start_horiz:
start_right = center_end.x() > center_start.x()
if start_right:
start_ex = self.width / 2
else:
start_ex = -self.width / 2
start_ey = start_ex * slope
else:
start_bottom = center_end.y() > center_start.y()
if start_bottom:
start_ey = self.height / 2
else:
start_ey = -self.height / 2
start_ex = start_ey / slope
return QPointF(center_start.x() + start_ex, center_start.y() + start_ey)
def set_agraph_node_properties(self, node: pygraphviz.Node):
node.attr['width'] = self.width / DPI
node.attr['height'] = self.height / DPI
node.attr['shape'] = 'box'
class HGExtern(HGNode):
def __init__(self, *args, radius=28., **kwargs):
self.radius = radius
self.objects = []
self.object_bg: Optional[QGraphicsEllipseItem] = None
self.object_text = None
super().__init__(parent)
super().__init__(*args, **kwargs)
self._layout()
@ -409,7 +605,7 @@ class PropExtern(PropNode):
return self.center + edge_vector
def set_agraph_node_properties(self, node: pygraphviz.Node):
node.attr['width'] = self.radius * 2 / 72
node.attr['width'] = self.radius * 2 / DPI
node.attr['shape'] = 'circle'
def boundingRect(self) -> QRectF:
@ -421,8 +617,8 @@ class PropExtern(PropNode):
def paint(self, painter, option, widget=...):
pass
class PropArrow(QGraphicsItem):
def __init__(self, parent, start: PropNode, end: PropNode, toward_start: bool, toward_end: bool, stroke=3., arrow_size=8.):
class HGArrow(AnimatableItem):
def __init__(self, parent, start: HGNode, end: HGNode, toward_start: bool, toward_end: bool, stroke=3., arrow_size=8.):
self.start = start
self.end = end
self.toward_start = toward_start
@ -432,7 +628,23 @@ class PropArrow(QGraphicsItem):
self._percentage = 1.
super().__init__(parent)
self.layout()
def enter(self, duration=250):
self._start_animation(self._animate_enter, duration)
def _animate_enter(self, value):
self.setOpacity(value)
self.percentage = value
def exit(self, duration=250):
self._start_animation(self._animate_exit, duration, self._finish_exit)
def _animate_exit(self, value):
self.setOpacity(1 - value)
self.percentage = 1 - value
def _finish_exit(self):
self.scene().removeItem(self)
@property
def percentage(self):
@ -455,15 +667,18 @@ class PropArrow(QGraphicsItem):
self.rel_end = end_pt - start_pt
def orient(self, start: PropChart):
if start is self.start:
pass
elif start is self.end:
def orient(self, anchor: HGNode, away=False):
if anchor is self.start:
anchor_start = True
elif anchor is self.end:
anchor_start = False
else:
raise ValueError("Orienting from node which is not associated with edge")
if away ^ (not anchor_start):
self.start, self.end = self.end, self.start
self.toward_start, self.toward_end = self.toward_end, self.toward_start
self.layout()
else:
raise ValueError("Orienting from node which is not associated with edge")
def boundingRect(self):
x = 0
@ -543,3 +758,9 @@ def rotate(pt: QPointF, angle: float) -> QPointF:
x = pt.x()
y = pt.y()
return QPointF(x * cost - y * sint, x * sint + y * cost)
def rand_point(range: QRectF, seed=None) -> QPointF:
r = random.Random(seed)
x = r.random()
y = r.random()
return QPointF(range.width() * x + range.x(), range.height() * y + range.y())