From a3ccf68845a468c8dbe30dd55c1f89a49f60eb49 Mon Sep 17 00:00:00 2001 From: Audrey Dutcher Date: Sat, 5 Nov 2022 14:39:06 -0700 Subject: [PATCH] drag it! animate it! --- typetapper/hierarchy_graph_view.py | 571 ++++++++++++++++++++--------- 1 file changed, 396 insertions(+), 175 deletions(-) diff --git a/typetapper/hierarchy_graph_view.py b/typetapper/hierarchy_graph_view.py index 513c711..405a6bd 100644 --- a/typetapper/hierarchy_graph_view.py +++ b/typetapper/hierarchy_graph_view.py @@ -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())