From 37132bb2acd503c34be999e2c60b9a6b9ae5a874 Mon Sep 17 00:00:00 2001 From: pedohorse <13556996+pedohorse@users.noreply.github.com> Date: Sun, 8 Sep 2024 00:12:17 +0200 Subject: [PATCH 1/6] move graphics_items to a package --- src/lifeblood_viewer/graphics_items/__init__.py | 4 ++++ .../{ => graphics_items}/graphics_items.py | 8 ++++---- src/lifeblood_viewer/{ => graphics_items}/network_item.py | 0 .../{ => graphics_items}/network_item_watchers.py | 0 .../{ => graphics_items}/node_extra_items.py | 0 .../nodeeditor_windows/ui_task_list_window.py | 3 +-- 6 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 src/lifeblood_viewer/graphics_items/__init__.py rename src/lifeblood_viewer/{ => graphics_items}/graphics_items.py (99%) rename src/lifeblood_viewer/{ => graphics_items}/network_item.py (100%) rename src/lifeblood_viewer/{ => graphics_items}/network_item_watchers.py (100%) rename src/lifeblood_viewer/{ => graphics_items}/node_extra_items.py (100%) diff --git a/src/lifeblood_viewer/graphics_items/__init__.py b/src/lifeblood_viewer/graphics_items/__init__.py new file mode 100644 index 00000000..66155f77 --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/__init__.py @@ -0,0 +1,4 @@ +# export inner classes +from .graphics_items import Node, Task, NodeConnection +from .network_item import NetworkItem, NetworkItemWithUI +from .network_item_watchers import NetworkItemWatcher, WatchableNetworkItem, WatchableNetworkItemProxy diff --git a/src/lifeblood_viewer/graphics_items.py b/src/lifeblood_viewer/graphics_items/graphics_items.py similarity index 99% rename from src/lifeblood_viewer/graphics_items.py rename to src/lifeblood_viewer/graphics_items/graphics_items.py index eb6a372a..30525e14 100644 --- a/src/lifeblood_viewer/graphics_items.py +++ b/src/lifeblood_viewer/graphics_items/graphics_items.py @@ -5,7 +5,7 @@ from math import sqrt from types import MappingProxyType from datetime import timedelta -from .code_editor.editor import StringParameterEditor +from ..code_editor.editor import StringParameterEditor from .node_extra_items import ImplicitSplitVisualizer from .network_item import NetworkItemWithUI, NetworkItem from .network_item_watchers import NetworkItemWatcher, WatchableNetworkItem, WatchableNetworkItemProxy @@ -29,10 +29,10 @@ from typing import FrozenSet, TYPE_CHECKING, Optional, List, Tuple, Dict, Set, Callable, Iterable, Union -from . import nodeeditor -from .editor_scene_integration import fetch_and_open_log_viewer +from .. import nodeeditor # TODO: break dep loop +from ..editor_scene_integration import fetch_and_open_log_viewer if TYPE_CHECKING: - from .graphics_scene import QGraphicsImguiScene + from ..graphics_scene import QGraphicsImguiScene logger = logging.get_logger('viewer') diff --git a/src/lifeblood_viewer/network_item.py b/src/lifeblood_viewer/graphics_items/network_item.py similarity index 100% rename from src/lifeblood_viewer/network_item.py rename to src/lifeblood_viewer/graphics_items/network_item.py diff --git a/src/lifeblood_viewer/network_item_watchers.py b/src/lifeblood_viewer/graphics_items/network_item_watchers.py similarity index 100% rename from src/lifeblood_viewer/network_item_watchers.py rename to src/lifeblood_viewer/graphics_items/network_item_watchers.py diff --git a/src/lifeblood_viewer/node_extra_items.py b/src/lifeblood_viewer/graphics_items/node_extra_items.py similarity index 100% rename from src/lifeblood_viewer/node_extra_items.py rename to src/lifeblood_viewer/graphics_items/node_extra_items.py diff --git a/src/lifeblood_viewer/nodeeditor_windows/ui_task_list_window.py b/src/lifeblood_viewer/nodeeditor_windows/ui_task_list_window.py index e117aa23..7377714f 100644 --- a/src/lifeblood_viewer/nodeeditor_windows/ui_task_list_window.py +++ b/src/lifeblood_viewer/nodeeditor_windows/ui_task_list_window.py @@ -4,8 +4,7 @@ from lifeblood.enums import TaskState from lifeblood_viewer.nodeeditor import NodeEditor from lifeblood_viewer.ui_scene_elements import ImguiViewWindow -from ..graphics_items import Node, Task -from ..network_item_watchers import NetworkItemWatcher +from ..graphics_items import Node, Task, NetworkItemWatcher from PySide2.QtCore import QPoint from PySide2.QtGui import QCursor From b9d903347392b8cc9505bf6bed62aa56126d2d8d Mon Sep 17 00:00:00 2001 From: pedohorse <13556996+pedohorse@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:05:24 +0200 Subject: [PATCH 2/6] initial graphics_items refactoring --- .../fancy_scene_item_factory.py | 24 + .../graphics_items/__init__.py | 2 +- .../dataaware_graphics_items.py | 1662 ++++++++++++++++ .../graphics_items/graphics_items.py | 1730 +---------------- .../graphics_items/node_extra_items.py | 1 + src/lifeblood_viewer/graphics_items/utils.py | 13 + src/lifeblood_viewer/graphics_scene.py | 520 ++--- src/lifeblood_viewer/graphics_scene_base.py | 61 + .../graphics_scene_container.py | 89 + .../graphics_scene_viewing_widget.py | 12 + src/lifeblood_viewer/nodeeditor.py | 32 +- src/lifeblood_viewer/scene_data_controller.py | 174 ++ .../scene_item_factory_base.py | 14 + 13 files changed, 2380 insertions(+), 1954 deletions(-) create mode 100644 src/lifeblood_viewer/fancy_scene_item_factory.py create mode 100644 src/lifeblood_viewer/graphics_items/dataaware_graphics_items.py create mode 100644 src/lifeblood_viewer/graphics_items/utils.py create mode 100644 src/lifeblood_viewer/graphics_scene_base.py create mode 100644 src/lifeblood_viewer/graphics_scene_container.py create mode 100644 src/lifeblood_viewer/graphics_scene_viewing_widget.py create mode 100644 src/lifeblood_viewer/scene_data_controller.py create mode 100644 src/lifeblood_viewer/scene_item_factory_base.py diff --git a/src/lifeblood_viewer/fancy_scene_item_factory.py b/src/lifeblood_viewer/fancy_scene_item_factory.py new file mode 100644 index 00000000..e9a080f4 --- /dev/null +++ b/src/lifeblood_viewer/fancy_scene_item_factory.py @@ -0,0 +1,24 @@ +from .graphics_items import Node, Task, NodeConnection +from .graphics_scene_base import GraphicsSceneBase +from lifeblood.ui_protocol_data import TaskData + +from .scene_data_controller import SceneDataController +from .scene_item_factory_base import SceneItemFactoryBase +from .graphics_items.dataaware_graphics_items import SceneNode, SceneTask, SceneNodeConnection + + +class FancySceneItemFactory(SceneItemFactoryBase): + def __init__(self, data_controller: SceneDataController): + self.__data_controller = data_controller + + def set_data_controller(self, data_controller: SceneDataController): + self.__data_controller = data_controller + + def make_task(self, scene: GraphicsSceneBase, task_data: TaskData) -> Task: + return SceneTask(scene, task_data, self.__data_controller) + + def make_node(self, scene: GraphicsSceneBase, id: int, type: str, name: str) -> Node: + return SceneNode(scene, id, type, name, self.__data_controller) + + def make_node_connection(self, scene: GraphicsSceneBase, id: int, nodeout: Node, nodein: Node, outname: str, inname: str) -> NodeConnection: + return SceneNodeConnection(scene, id, nodeout, nodein, outname, inname, self.__data_controller) diff --git a/src/lifeblood_viewer/graphics_items/__init__.py b/src/lifeblood_viewer/graphics_items/__init__.py index 66155f77..6ab6160a 100644 --- a/src/lifeblood_viewer/graphics_items/__init__.py +++ b/src/lifeblood_viewer/graphics_items/__init__.py @@ -1,4 +1,4 @@ # export inner classes -from .graphics_items import Node, Task, NodeConnection +from .graphics_items import Node, Task, NodeConnection, SceneNetworkItemWithUI from .network_item import NetworkItem, NetworkItemWithUI from .network_item_watchers import NetworkItemWatcher, WatchableNetworkItem, WatchableNetworkItemProxy diff --git a/src/lifeblood_viewer/graphics_items/dataaware_graphics_items.py b/src/lifeblood_viewer/graphics_items/dataaware_graphics_items.py new file mode 100644 index 00000000..2c52819b --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/dataaware_graphics_items.py @@ -0,0 +1,1662 @@ +from datetime import timedelta +from math import sqrt +import imgui +from lifeblood import logging +from lifeblood.config import get_config +from lifeblood.enums import TaskState, NodeParameterType, InvocationState +from lifeblood.uidata import CollapsableVerticalGroup, OneLineParametersLayout, Parameter, ParameterExpressionError, ParametersLayoutBase, Separator, NodeUi +from lifeblood.ui_protocol_data import TaskData, IncompleteInvocationLogData, InvocationLogData +from .graphics_items import Node, NodeConnection, Task +from .network_item_watchers import NetworkItemWatcher +from .node_extra_items import ImplicitSplitVisualizer +from .utils import call_later, length2 +from ..editor_scene_integration import fetch_and_open_log_viewer +from ..scene_data_controller import SceneDataController +from ..code_editor.editor import StringParameterEditor +from ..graphics_scene_container import GraphicsSceneWithNodesAndTasks +from ..graphics_scene_viewing_widget import GraphicsSceneViewingWidgetBase + +from PySide2.QtCore import QAbstractAnimation, Qt, Slot, QPointF, QRectF, QSizeF, QSequentialAnimationGroup +from PySide2.QtGui import QBrush, QColor, QDesktopServices, QLinearGradient, QPainter, QPainterPath, QPainterPathStroker, QPen +from PySide2.QtWidgets import QGraphicsItem, QStyleOptionGraphicsItem, QGraphicsSceneMouseEvent, QWidget + +from typing import Callable, Iterable, List, Optional, Set, Tuple + + +logger = logging.get_logger('viewer') + + +class SnapPoint: + def pos(self) -> QPointF: + raise NotImplementedError() + + +class NodeConnSnapPoint(SnapPoint): + def __init__(self, node: Node, connection_name: str, connection_is_input: bool): + super().__init__() + self.__node = node + self.__conn_name = connection_name + self.__isinput = connection_is_input + + def node(self) -> Node: + return self.__node + + def connection_name(self) -> str: + return self.__conn_name + + def connection_is_input(self) -> bool: + return self.__isinput + + def pos(self) -> QPointF: + if self.__isinput: + return self.__node.get_input_position(self.__conn_name) + return self.__node.get_output_position(self.__conn_name) + + +class TaskAnimation(QAbstractAnimation): + def __init__(self, task: "Task", node1: "Node", pos1: "QPointF", node2: "Node", pos2: "QPointF", duration: int, parent): + super().__init__(parent) + self.__task = task + + self.__node1 = node1 + self.__pos1 = pos1 + self.__node2 = node2 + self.__pos2 = pos2 + self.__duration = max(duration, 1) + self.__started = False + self.__anim_type = 0 if self.__node1 is self.__node2 else 1 + + def duration(self) -> int: + return self.__duration + + def updateCurrentTime(self, currentTime: int) -> None: + if not self.__started: + self.__started = True + + pos1 = self.__pos1 + if self.__node1: + pos1 = self.__node1.mapToScene(pos1) + + pos2 = self.__pos2 + if self.__node2: + pos2 = self.__node2.mapToScene(pos2) + + t = currentTime / self.duration() + if self.__anim_type == 0: # linear + pos = pos1 * (1 - t) + pos2 * t + else: # cubic + curv = min((pos2-pos1).manhattanLength() * 2, 1000) # 1000 is kinda derivative + a = QPointF(0, curv) - (pos2-pos1) + b = QPointF(0, -curv) + (pos2-pos1) + pos = pos1*(1-t) + pos2*t + t*(1-t)*(a*(1-t) + b*t) + self.__task.setPos(pos) + + +class SceneNode(Node): + base_height = 100 + base_width = 150 + + def __init__(self, scene: GraphicsSceneWithNodesAndTasks, id: int, type: str, name: str, data_controller: SceneDataController): + super().__init__(scene, id, type, name) + self.__scene_container = scene + self.__data_controller: SceneDataController = data_controller + self.__visual_tasks: List[Task] = [] + + # display + self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemSendsGeometryChanges) + self.setAcceptHoverEvents(True) + self.__nodeui_menucache = {} + self.__ui_selected_tab = 0 + + self.__hoverover_pos: Optional[QPointF] = None + self.__height = self.base_height + self.__width = self.base_width + self.__pivot_x = 0 + self.__pivot_y = 0 + + self.__ui_interactor = None + self.__ui_grabbed_conn = None + self.__ui_widget: Optional[GraphicsSceneViewingWidgetBase] = None + + self.__move_start_position = None + self.__move_start_selection = None + + self.__input_radius = 12 + self.__input_visible_radius = 8 + self.__line_width = 1 + + self.__node_ui_for_io_requested = False + + # prepare default drawing tools + self.__borderpen = QPen(QColor(96, 96, 96, 255)) + self.__borderpen_selected = QPen(QColor(144, 144, 144, 255)) + self.__caption_pen = QPen(QColor(192, 192, 192, 255)) + self.__typename_pen = QPen(QColor(128, 128, 128, 192)) + self.__borderpen.setWidthF(self.__line_width) + self.__header_brush = QBrush(QColor(48, 64, 48, 192)) + self.__body_brush = QBrush(QColor(48, 48, 48, 128)) + self.__connector_brush = QBrush(QColor(48, 48, 48, 192)) + self.__connector_brush_hovered = QBrush(QColor(96, 96, 96, 128)) + + self.__expanded = False + + self.__cached_bounds = None + self.__cached_nodeshape = None + self.__cached_bodymask = None + self.__cached_headershape = None + self.__cached_bodyshape = None + self.__cached_expandbutton_shape = None + + # misc + self.__manual_url_base = get_config('viewer').get_option_noasync('manual_base_url', 'https://pedohorse.github.io/lifeblood') + + # children! + self.__vismark = ImplicitSplitVisualizer(self) + self.__vismark.setPos(QPointF(0, self._get_nodeshape().boundingRect().height() * 0.5)) + self.__vismark.setZValue(-2) + + def apply_settings(self, settings_name: str): + self.__data_controller.request_apply_node_settings(self.get_id(), settings_name) + + def pause_all_tasks(self): + self.__data_controller.set_tasks_paused([x.get_id() for x in self.tasks_iter()], True) + + def resume_all_tasks(self): + self.__data_controller.set_tasks_paused([x.get_id() for x in self.tasks_iter()], False) + + def update_nodeui(self, nodeui: NodeUi): + super().update_nodeui(nodeui) + self.__nodeui_menucache = {} + + def set_expanded(self, expanded: bool): + if self.__expanded == expanded: + return + self.__expanded = expanded + self.prepareGeometryChange() + self.__height = self.base_height + if expanded: + self.__height += 225 + self.__pivot_y -= 225/2 + # self.setPos(self.pos() + QPointF(0, 225*0.5)) + else: + self.__pivot_y = 0 + # self.setPos(self.pos() - QPointF(0, 225 * 0.5)) # TODO: modify painterpath getters to avoid moving nodes on expand + self.__vismark.setPos(QPointF(0, self._get_nodeshape().boundingRect().height() * 0.5)) + + for i, task in enumerate(self.tasks()): + self.__make_task_child_with_position(task, *self.get_task_pos(task, i), animate=True) + + def get_input_position(self, name: str = 'main') -> QPointF: + if not self.input_names(): + idx = 0 + cnt = 1 + elif name not in self.input_names(): + raise RuntimeError(f'unexpected input name {name}') + else: + idx = self.input_names().index(name) + cnt = len(self.input_names()) + assert cnt > 0 + return self.mapToScene(-0.5 * self.__width + (idx + 1) * self.__width/(cnt + 1) - self.__pivot_x, + -0.5 * self.__height - self.__pivot_y) + + def get_output_position(self, name: str = 'main') -> QPointF: + if not self.output_names(): + idx = 0 + cnt = 1 + elif name not in self.output_names(): + raise RuntimeError(f'unexpected output name {name} , {self.output_names()}') + else: + idx = self.output_names().index(name) + cnt = len(self.output_names()) + assert cnt > 0 + return self.mapToScene(-0.5 * self.__width + (idx + 1) * self.__width/(cnt + 1) - self.__pivot_x, + 0.5 * self.__height - self.__pivot_y) + + def input_snap_points(self): + # TODO: cache snap points, don't recalc them every time + if self.get_nodeui() is None: + return [] + inputs = [] + for input_name in self.get_nodeui().inputs_names(): + inputs.append(NodeConnSnapPoint(self, input_name, True)) + return inputs + + def output_snap_points(self): + # TODO: cache snap points, don't recalc them every time + if self.get_nodeui() is None: + return [] + outputs = [] + for output_name in self.get_nodeui().outputs_names(): + outputs.append(NodeConnSnapPoint(self, output_name, False)) + return outputs + + + # move animation + + def get_task_pos(self, task: "Task", pos_id: int) -> Tuple[QPointF, int]: + rect = self._get_bodyshape().boundingRect() + x, y = rect.topLeft().toTuple() + w, h = rect.size().toTuple() + d = task.draw_size() # TODO: this assumes size is same, so dont make it an instance method + r = d * 0.5 + + #w *= 0.5 + x += r + y += r + h -= d + w -= d + x += (d * pos_id % w) + y_shift = d * int(d * pos_id / w) + y += (y_shift % h) + return QPointF(x, y), int(y_shift / h) + + def __make_task_child_with_position(self, task: "Task", pos: QPointF, layer: int, *, animate: bool = False): + """ + helper function that actually changes parent of a task and initializes animations if needed + """ + assert isinstance(task, SceneTask) # TODO: hmmm + if animate: + task.append_task_move_animation(self, pos, layer) + else: + task.set_task_position(self, pos, layer) + + def add_task(self, task: "Task"): + if task in self.__visual_tasks: + assert task in self.tasks() + return + + # the animated part + pos_id = len(self.__visual_tasks) + if task.node() is None: + self.__make_task_child_with_position(task, *self.get_task_pos(task, pos_id)) + else: + self.__make_task_child_with_position(task, *self.get_task_pos(task, pos_id), animate=True) + + super().add_task(task) + insert_at = self._find_insert_index_for_task(task, prefer_back=True) + + self.__visual_tasks.append(None) # temporary placeholder, it'll be eliminated either in the loop, or after if task is last + for i in reversed(range(insert_at + 1, len(self.__visual_tasks))): + self.__visual_tasks[i] = self.__visual_tasks[i - 1] # TODO: animated param should affect below! + self.__make_task_child_with_position(self.__visual_tasks[i], *self.get_task_pos(self.__visual_tasks[i], i), animate=True) + self.__visual_tasks[insert_at] = task + self.__make_task_child_with_position(self.__visual_tasks[insert_at], *self.get_task_pos(task, insert_at), animate=True) + + def remove_tasks(self, tasks_to_remove: Iterable["Task"]): + tasks_to_remove = set(tasks_to_remove) + super().remove_tasks(tasks_to_remove) + + self.__visual_tasks: List["Task"] = [None if x in tasks_to_remove else x for x in self.__visual_tasks] + off = 0 + for i, task in enumerate(self.__visual_tasks): + if task is None: + off += 1 + else: + self.__visual_tasks[i - off] = self.__visual_tasks[i] + self.__make_task_child_with_position(self.__visual_tasks[i - off], *self.get_task_pos(self.__visual_tasks[i - off], i - off), animate=True) + self.__visual_tasks = self.__visual_tasks[:-off] + for x in tasks_to_remove: + assert x not in self.__visual_tasks + + def remove_task(self, task_to_remove: "Task"): + super().remove_task(task_to_remove) + task_pid = self.__visual_tasks.index(task_to_remove) + + for i in range(task_pid, len(self.__visual_tasks) - 1): + self.__visual_tasks[i] = self.__visual_tasks[i + 1] + self.__make_task_child_with_position(self.__visual_tasks[i], *self.get_task_pos(self.__visual_tasks[i], i), animate=True) + self.__visual_tasks = self.__visual_tasks[:-1] + assert task_to_remove not in self.__visual_tasks + self.item_updated(redraw=True, ui=False) # cuz node displays task number - we should redraw + + def _find_insert_index_for_task(self, task, prefer_back=False): + if task.state() == TaskState.IN_PROGRESS and not prefer_back: + return 0 + + if task.state() != TaskState.IN_PROGRESS and prefer_back: + return len(self.__visual_tasks) + + # now fun thing: we either have IN_PROGRESS and prefer_back, or NOT IN_PROGRESS and NOT prefer_back + # and both cases have the same logic for position finding + for i, task in enumerate(self.__visual_tasks): + if task.state() != TaskState.IN_PROGRESS: + return i + else: + return len(self.__visual_tasks) + + def task_state_changed(self, task): + """ + here node might decide to highlight the task that changed state one way or another + """ + if task.state() not in (TaskState.IN_PROGRESS, TaskState.GENERATING, TaskState.POST_GENERATING): + return + + # find a place + append_at = self._find_insert_index_for_task(task) + + if append_at == len(self.__visual_tasks): # this is impossible case (in current impl of _find_insert_index_for_task) (cuz task is in __visual_tasks, and it's not in IN_PROGRESS) + return + + idx = self.__visual_tasks.index(task) + if idx <= append_at: # already in place (and ignore moving further + return + + # place where it has to be + for i in reversed(range(append_at + 1, idx+1)): + self.__visual_tasks[i] = self.__visual_tasks[i-1] + self.__make_task_child_with_position(self.__visual_tasks[i], *self.get_task_pos(self.__visual_tasks[i], i), animate=True) + self.__visual_tasks[append_at] = task + self.__make_task_child_with_position(self.__visual_tasks[append_at], *self.get_task_pos(task, append_at), animate=True) + + # + # interface + + # helper + def __draw_single_item(self, item, size=(1.0, 1.0), drawing_widget=None): + if isinstance(item, Parameter): + if not item.visible(): + return + param_name = item.name() + param_label = item.label() or '' + parent_layout = item.parent() + idstr = f'_{self.get_id()}' + assert isinstance(parent_layout, ParametersLayoutBase) + imgui.push_item_width(imgui.get_window_width() * parent_layout.relative_size_for_child(item)[0] * 2 / 3) + + changed = False + expr_changed = False + + new_item_val = None + new_item_expression = None + + try: + if item.has_expression(): + with imgui.colored(imgui.COLOR_FRAME_BACKGROUND, 0.1, 0.4, 0.1): + expr_changed, newval = imgui.input_text('##'.join((param_label, param_name, idstr)), item.expression(), 256, flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) + if expr_changed: + new_item_expression = newval + elif item.has_menu(): + menu_order, menu_items = item.get_menu_items() + + if param_name not in self.__nodeui_menucache: + self.__nodeui_menucache[param_name] = {'menu_items_inv': {v: k for k, v in menu_items.items()}, + 'menu_order_inv': {v: i for i, v in enumerate(menu_order)}} + + menu_items_inv = self.__nodeui_menucache[param_name]['menu_items_inv'] + menu_order_inv = self.__nodeui_menucache[param_name]['menu_order_inv'] + if item.is_readonly() or item.is_locked(): # TODO: treat locked items somehow different, but for now it's fine + imgui.text(menu_items_inv[item.value()]) + return + else: + changed, val = imgui.combo('##'.join((param_label, param_name, idstr)), menu_order_inv[menu_items_inv[item.value()]], menu_order) + if changed: + new_item_val = menu_items[menu_order[val]] + else: + if item.is_readonly() or item.is_locked(): # TODO: treat locked items somehow different, but for now it's fine + imgui.text(f'{item.value()}') + if item.label(): + imgui.same_line() + imgui.text(f'{item.label()}') + return + param_type = item.type() + if param_type == NodeParameterType.BOOL: + changed, newval = imgui.checkbox('##'.join((param_label, param_name, idstr)), item.value()) + elif param_type == NodeParameterType.INT: + #changed, newval = imgui.slider_int('##'.join((param_label, param_name, idstr)), item.value(), 0, 10) + slider_limits = item.display_value_limits() + if slider_limits[0] is not None: + changed, newval = imgui.slider_int('##'.join((param_label, param_name, idstr)), item.value(), *slider_limits) + else: + changed, newval = imgui.input_int('##'.join((param_label, param_name, idstr)), item.value(), flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) + if imgui.begin_popup_context_item(f'item context menu##{param_name}', 2): + imgui.selectable('toggle expression') + imgui.end_popup() + elif param_type == NodeParameterType.FLOAT: + #changed, newval = imgui.slider_float('##'.join((param_label, param_name, idstr)), item.value(), 0, 10) + slider_limits = item.display_value_limits() + if slider_limits[0] is not None and slider_limits[1] is not None: + changed, newval = imgui.slider_float('##'.join((param_label, param_name, idstr)), item.value(), *slider_limits) + else: + changed, newval = imgui.input_float('##'.join((param_label, param_name, idstr)), item.value(), flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) + elif param_type == NodeParameterType.STRING: + if item.is_text_multiline(): + # TODO: this below is a temporary solution. it only gives 8192 extra symbols for editing, but currently there is no proper way around with current pyimgui version + imgui.begin_group() + ed_butt_pressed = imgui.small_button(f'open in external window##{param_name}') + changed, newval = imgui.input_text_multiline('##'.join((param_label, param_name, idstr)), item.unexpanded_value(), len(item.unexpanded_value()) + 1024*8, flags=imgui.INPUT_TEXT_ALLOW_TAB_INPUT | imgui.INPUT_TEXT_ENTER_RETURNS_TRUE | imgui.INPUT_TEXT_CTRL_ENTER_FOR_NEW_LINE) + imgui.end_group() + if ed_butt_pressed: + hl = StringParameterEditor.SyntaxHighlight.NO_HIGHLIGHT + if item.syntax_hint() == 'python': + hl = StringParameterEditor.SyntaxHighlight.PYTHON + wgt = StringParameterEditor(syntax_highlight=hl, parent=drawing_widget) + wgt.setAttribute(Qt.WA_DeleteOnClose, True) + wgt.set_text(item.unexpanded_value()) + wgt.edit_done.connect(lambda x, sc=self.scene(), id=self.get_id(), it=item: sc.change_node_parameter(id, item, x)) + wgt.set_title(f'editing parameter "{param_name}"') + wgt.show() + else: + changed, newval = imgui.input_text('##'.join((param_label, param_name, idstr)), item.unexpanded_value(), 256, flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) + else: + raise NotImplementedError() + if changed: + new_item_val = newval + + # item context menu popup + popupid = '##'.join((param_label, param_name, idstr)) # just to make sure no names will collide with full param imgui lables + if imgui.begin_popup_context_item(f'Item Context Menu##{popupid}', 2): + if item.can_have_expressions() and not item.has_expression(): + if imgui.selectable(f'enable expression##{popupid}')[0]: + expr_changed = True + # try to turn backtick expressions into normal one + if item.type() == NodeParameterType.STRING: + new_item_expression = item.python_from_expandable_string(item.unexpanded_value()) + else: + new_item_expression = str(item.value()) + if item.has_expression(): + if imgui.selectable(f'delete expression##{popupid}')[0]: + try: + value = item.value() + except ParameterExpressionError as e: + value = item.default_value() + expr_changed = True + changed = True + new_item_val = value + new_item_expression = None + imgui.end_popup() + finally: + imgui.pop_item_width() + + if changed or expr_changed: + # TODO: op below may fail, so callback to display error should be provided + self.__data_controller.change_node_parameter(self.get_id(), item, + new_item_val if changed else ..., + new_item_expression if expr_changed else ...) + + elif isinstance(item, Separator): + imgui.separator() + elif isinstance(item, OneLineParametersLayout): + first_time = True + for child in item.items(recursive=False): + h, w = item.relative_size_for_child(child) + if isinstance(child, Parameter): + if not child.visible(): + continue + if first_time: + first_time = False + else: + imgui.same_line() + self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) + elif isinstance(item, CollapsableVerticalGroup): + expanded, _ = imgui.collapsing_header(f'{item.label()}##{item.name()}') + if expanded: + imgui.indent(5) + for child in item.items(recursive=False): + h, w = item.relative_size_for_child(child) + self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) + imgui.unindent(5) + imgui.separator() + elif isinstance(item, ParametersLayoutBase): + imgui.indent(5) + for child in item.items(recursive=False): + h, w = item.relative_size_for_child(child) + if isinstance(child, Parameter): + if not child.visible(): + continue + self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) + imgui.unindent(5) + elif isinstance(item, ParametersLayoutBase): + for child in item.items(recursive=False): + h, w = item.relative_size_for_child(child) + if isinstance(child, Parameter): + if not child.visible(): + continue + self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) + else: + raise NotImplementedError(f'unknown parameter hierarchy item to display {type(item)}') + + # main dude + def draw_imgui_elements(self, drawing_widget): + imgui.text(f'Node {self.get_id()}, type "{self.node_type()}", name {self.node_name()}') + + if imgui.selectable(f'parameters##{self.node_name()}', self.__ui_selected_tab == 0, width=imgui.get_window_width() * 0.5 * 0.7)[1]: + self.__ui_selected_tab = 0 + imgui.same_line() + if imgui.selectable(f'description##{self.node_name()}', self.__ui_selected_tab == 1, width=imgui.get_window_width() * 0.5 * 0.7)[1]: + self.__ui_selected_tab = 1 + imgui.separator() + + if self.__ui_selected_tab == 0: + if (nodeui := self.get_nodeui()) is not None: + self.__draw_single_item(nodeui.main_parameter_layout(), drawing_widget=drawing_widget) + elif self.__ui_selected_tab == 1: + + if (node_type := self.node_type()) in self.__data_controller.node_types() and imgui.button('open manual page'): + plugin_info = self.__data_controller.node_types()[node_type].plugin_info + category = plugin_info.category + package = plugin_info.package_name + QDesktopServices.openUrl(self.__manual_url_base + f'/nodes/{category}{f"/{package}" if package else ""}/{self.node_type()}.html') + imgui.text(self.__data_controller.node_types()[self.node_type()].description if self.node_type() in self.__data_controller.node_types() else 'error') + + # + # scene item + # + + def boundingRect(self) -> QRectF: + if self.__cached_bounds is None: + lw = self.__width + self.__line_width + lh = self.__height + self.__line_width + self.__cached_bounds = QRectF( + -0.5 * lw - self.__pivot_x, + -0.5 * lh - (max(self.__input_radius, self.__input_visible_radius) + 0.5 * self.__line_width) - self.__pivot_y, + lw, + lh + 2 * (max(self.__input_radius, self.__input_visible_radius) + 0.5 * self.__line_width)) + return self.__cached_bounds + + def _get_nodeshape(self): + if self.__cached_nodeshape is None: + lw = self.__width + self.__line_width + lh = self.__height + self.__line_width + nodeshape = QPainterPath() + nodeshape.addRoundedRect(QRectF(-0.5 * lw - self.__pivot_x, -0.5 * lh - self.__pivot_y, lw, lh), 5, 5) + self.__cached_nodeshape = nodeshape + return self.__cached_nodeshape + + def _get_bodymask(self): + if self.__cached_bodymask is None: + lw = self.__width + self.__line_width + lh = self.__height + self.__line_width + bodymask = QPainterPath() + bodymask.addRect(-0.5 * lw - self.__pivot_x, -0.5 * lh + 32 - self.__pivot_y, lw, lh - 32) + self.__cached_bodymask = bodymask + return self.__cached_bodymask + + def _get_headershape(self): + if self.__cached_headershape is None: + self.__cached_headershape = self._get_nodeshape() - self._get_bodymask() + return self.__cached_headershape + + def _get_bodyshape(self): + if self.__cached_bodyshape is None: + self.__cached_bodyshape = self._get_nodeshape() & self._get_bodymask() + return self.__cached_bodyshape + + def _get_expandbutton_shape(self): + if self.__cached_expandbutton_shape is None: + bodyshape = self._get_bodyshape() + mask = QPainterPath() + body_bound = bodyshape.boundingRect() + corner = body_bound.bottomRight() + QPointF(15, 15) + top = corner + QPointF(0, -60) + left = corner + QPointF(-60, 0) + mask.moveTo(corner) + mask.lineTo(top) + mask.lineTo(left) + mask.lineTo(corner) + self.__cached_expandbutton_shape = bodyshape & mask + return self.__cached_expandbutton_shape + + def reanalyze_nodeui(self): + self.prepareGeometryChange() # not calling this seem to be able to break scene's internal index info on our connections + # bug that appears - on first scene load deleting a node with more than 1 input/output leads to crash + # on open nodes have 1 output, then they receive interface update and this func is called, and here's where bug may happen + + super().reanalyze_nodeui() + css = self.get_nodeui().color_scheme() + if css.secondary_color() is not None: + gradient = QLinearGradient(-self.__width*0.1, 0, self.__width*0.1, 16) + gradient.setColorAt(0.0, QColor(*(x * 255 for x in css.main_color()), 192)) + gradient.setColorAt(1.0, QColor(*(x * 255 for x in css.secondary_color()), 192)) + self.__header_brush = QBrush(gradient) + else: + self.__header_brush = QBrush(QColor(*(x * 255 for x in css.main_color()), 192)) + self.item_updated(redraw=True, ui=True) # cuz input count affects visualization in the graph + + def prepareGeometryChange(self): + super().prepareGeometryChange() + self.__cached_bounds = None + self.__cached_nodeshape = None + self.__cached_bodymask = None + self.__cached_headershape = None + self.__cached_bodyshape = None + self.__cached_expandbutton_shape = None + for conn in self.all_connections(): + conn.prepareGeometryChange() + + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: + screen_rect = painter.worldTransform().mapRect(self.boundingRect()) + painter.pen().setWidthF(self.__line_width) + nodeshape = self._get_nodeshape() + + # this request from paint here is SUS + if not self.__node_ui_for_io_requested: + self.__node_ui_for_io_requested = True + self.__data_controller.request_node_ui(self.get_id()) + + if screen_rect.width() > 40: + ninputs = len(self.input_names()) + noutputs = len(self.output_names()) + r2 = (self.__input_radius + 0.5*self.__line_width)**2 + for fi in range(ninputs + noutputs): + path = QPainterPath() + is_inputs = fi < ninputs + i = fi if is_inputs else fi - ninputs + input_point = QPointF(-0.5 * self.__width + (i + 1) * self.__width/((ninputs if is_inputs else noutputs) + 1) - self.__pivot_x, + (-0.5 if is_inputs else 0.5) * self.__height - self.__pivot_y) + path.addEllipse(input_point, + self.__input_visible_radius, self.__input_visible_radius) + path -= nodeshape + pen = self.__borderpen + brush = self.__connector_brush + if self.__hoverover_pos is not None: + if QPointF.dotProduct(input_point - self.__hoverover_pos, input_point - self.__hoverover_pos) <= r2: + pen = self.__borderpen_selected + brush = self.__connector_brush_hovered + painter.setPen(pen) + painter.fillPath(path, brush) + painter.drawPath(path) + + headershape = self._get_headershape() + bodyshape = self._get_bodyshape() + + if self.isSelected(): + if screen_rect.width() > 100: + width_mult = 1 + elif screen_rect.width() > 50: + width_mult = 4 + elif screen_rect.width() > 25: + width_mult = 8 + else: + width_mult = 16 + self.__borderpen_selected.setWidth(self.__line_width*width_mult) + painter.setPen(self.__borderpen_selected) + else: + painter.setPen(self.__borderpen) + painter.fillPath(headershape, self.__header_brush) + painter.fillPath(bodyshape, self.__body_brush) + expand_button_shape = self._get_expandbutton_shape() + painter.fillPath(expand_button_shape, self.__header_brush) + painter.drawPath(nodeshape) + # draw highlighted elements on top + if self.__hoverover_pos and expand_button_shape.contains(self.__hoverover_pos): + painter.setPen(self.__borderpen_selected) + painter.drawPath(expand_button_shape) + + # draw header/text last + if screen_rect.width() > 50: + painter.setPen(self.__caption_pen) + painter.drawText(headershape.boundingRect(), Qt.AlignHCenter | Qt.AlignTop, self.node_name()) + painter.setPen(self.__typename_pen) + painter.drawText(headershape.boundingRect(), Qt.AlignRight | Qt.AlignBottom, self.node_type()) + painter.drawText(headershape.boundingRect(), Qt.AlignLeft | Qt.AlignBottom, f'{len(self.tasks())}') + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemSelectedHasChanged: + if value and self.graphics_scene().get_inspected_item() == self: # item was just selected, And is the first selected + self.__data_controller.request_node_ui(self.get_id()) + elif change == QGraphicsItem.ItemPositionChange: + if self.__move_start_position is None: + self.__move_start_position = self.pos() + for connection in self.all_connections(): + connection.prepareGeometryChange() + + return super().itemChange(change, value) + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent): + if event.button() == Qt.LeftButton and self.__ui_interactor is None: + wgt = event.widget().parent() + assert isinstance(wgt, GraphicsSceneViewingWidgetBase) + pos = event.scenePos() + r2 = (self.__input_radius + 0.5*self.__line_width)**2 + + # check expand button + expand_button_shape = self._get_expandbutton_shape() + if expand_button_shape.contains(event.pos()): + self.set_expanded(not self.__expanded) + event.ignore() + return + + for input in self.input_names(): + inpos = self.get_input_position(input) + if QPointF.dotProduct(inpos - pos, inpos - pos) <= r2 and wgt.request_ui_focus(self): + snap_points = [y for x in self.__scene_container.nodes() if x != self for y in x.output_snap_points()] + displayer = NodeConnectionCreatePreview(None, self, '', input, snap_points, 15, self._ui_interactor_finished) + self.scene().addItem(displayer) + self.__ui_interactor = displayer + self.__ui_grabbed_conn = input + self.__ui_widget = wgt + event.accept() + self.__ui_interactor.mousePressEvent(event) + return + + for output in self.output_names(): + outpos = self.get_output_position(output) + if QPointF.dotProduct(outpos - pos, outpos - pos) <= r2 and wgt.request_ui_focus(self): + snap_points = [y for x in self.__scene_container.nodes() if x != self for y in x.input_snap_points()] + displayer = NodeConnectionCreatePreview(self, None, output, '', snap_points, 15, self._ui_interactor_finished) + self.scene().addItem(displayer) + self.__ui_interactor = displayer + self.__ui_grabbed_conn = output + self.__ui_widget = wgt + event.accept() + self.__ui_interactor.mousePressEvent(event) + return + + if not self._get_nodeshape().contains(event.pos()): + event.ignore() + return + + super().mousePressEvent(event) + self.__move_start_selection = {self} + self.__move_start_position = None + + # check for special picking: shift+move should move all upper connected nodes + if event.modifiers() & Qt.ShiftModifier or event.modifiers() & Qt.ControlModifier: + selecting_inputs = event.modifiers() & Qt.ShiftModifier + selecting_outputs = event.modifiers() & Qt.ControlModifier + extra_selected_nodes = set() + if selecting_inputs: + extra_selected_nodes.update(self.input_nodes()) + if selecting_outputs: + extra_selected_nodes.update(self.output_nodes()) + + extra_selected_nodes_ordered = list(extra_selected_nodes) + for relnode in extra_selected_nodes_ordered: + relnode.setSelected(True) + relrelnodes = set() + if selecting_inputs: + relrelnodes.update(node for node in relnode.input_nodes() if node not in extra_selected_nodes) + if selecting_outputs: + relrelnodes.update(node for node in relnode.output_nodes() if node not in extra_selected_nodes) + extra_selected_nodes_ordered.extend(relrelnodes) + extra_selected_nodes.update(relrelnodes) + self.setSelected(True) + for item in self.scene().selectedItems(): + if isinstance(item, Node): + self.__move_start_selection.add(item) + item.__move_start_position = None + + if event.button() == Qt.RightButton: + # context menu time + view = event.widget().parent() + assert isinstance(view, GraphicsSceneViewingWidgetBase) + view.item_requests_context_menu(self) + event.accept() + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent): + # if self.__ui_interactor is not None: + # event.accept() + # self.__ui_interactor.mouseMoveEvent(event) + # return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): + # if self.__ui_interactor is not None: + # event.accept() + # self.__ui_interactor.mouseReleaseEvent(event) + # return + super().mouseReleaseEvent(event) + if self.__move_start_position is not None: + if self.__scene_container.node_snapping_enabled(): + for node in self.__move_start_selection: + pos = node.pos() + snapx = node.base_width / 4 + snapy = node.base_height / 4 + node.setPos(round(pos.x() / snapx) * snapx, + round(pos.y() / snapy) * snapy) + self.scene()._nodes_were_moved([(node, node.__move_start_position) for node in self.__move_start_selection]) + for node in self.__move_start_selection: + node.__move_start_position = None + + def hoverMoveEvent(self, event): + self.__hoverover_pos = event.pos() + + def hoverLeaveEvent(self, event): + self.__hoverover_pos = None + self.update() + + @Slot(object) + def _ui_interactor_finished(self, snap_point: Optional[NodeConnSnapPoint]): + assert self.__ui_interactor is not None + call_later(lambda x: logger.debug(f'later removing {x}') or x.scene().removeItem(x), self.__ui_interactor) + if self.scene() is None: # if scheduler deleted us while interacting + return + if self.__ui_widget is None: + raise RuntimeError('interaction finalizer called, but ui widget is not set') + + grabbed_conn = self.__ui_grabbed_conn + self.__ui_widget.release_ui_focus(self) + self.__ui_widget = None + self.__ui_interactor = None + self.__ui_grabbed_conn = None + + # actual node reconection + if snap_point is None: + logger.debug('no change') + return + + setting_out = not snap_point.connection_is_input() + self.__data_controller.add_connection(snap_point.node().get_id() if setting_out else self.get_id(), + snap_point.connection_name() if setting_out else grabbed_conn, + snap_point.node().get_id() if not setting_out else self.get_id(), + snap_point.connection_name() if not setting_out else grabbed_conn) + + +class SceneNodeConnection(NodeConnection): + def __init__(self, scene: GraphicsSceneWithNodesAndTasks, id: int, nodeout: Node, nodein: Node, outname: str, inname: str, data_controller: SceneDataController): + super().__init__(scene, id, nodeout, nodein, outname, inname) + self.__scene_container = scene + self.__data_controller: SceneDataController = data_controller + self.setFlags(QGraphicsItem.ItemSendsGeometryChanges) # QGraphicsItem.ItemIsSelectable | + self.setAcceptHoverEvents(True) # for highlights + + self.setZValue(-1) + self.__line_width = 6 # TODO: rename it to match what it represents + self.__wire_pick_radius = 15 + self.__pick_radius2 = 100 ** 2 + self.__curv = 150 + self.__wire_highlight_radius = 5 + + self.__temporary_invalid = False + + self.__ui_interactor: Optional[NodeConnectionCreatePreview] = None + + self.__ui_last_pos = QPointF() + self.__ui_grabbed_beginning: bool = True + + self.__pen = QPen(QColor(64, 64, 64, 192)) + self.__pen.setWidthF(3) + self.__pen_highlight = QPen(QColor(92, 92, 92, 192)) + self.__pen_highlight.setWidthF(3) + self.__thick_pen = QPen(QColor(144, 144, 144, 128)) + self.__thick_pen.setWidthF(4) + self.__last_drawn_path: Optional[QPainterPath] = None + + self.__stroker = QPainterPathStroker() + self.__stroker.setWidth(2 * self.__wire_pick_radius) + + self.__hoverover_pos = None + + # to ensure correct interaction + self.__ui_widget: Optional[GraphicsSceneViewingWidgetBase] = None + + def distance_to_point(self, pos: QPointF): + """ + returns approx distance to a given point + currently it has the most crude implementation + :param pos: + :return: + """ + + line = self.get_painter_path() + # determine where to start + outnode, outname = self.output() + innode, inname = self.input() + p0 = outnode.get_output_position(outname) + p1 = innode.get_input_position(inname) + + if length2(p0-pos) < length2(p1-pos): # pos closer to p0 + curper = 0 + curstep = 0.1 + lastsqlen = length2(p0 - pos) + else: + curper = 1 + curstep = -0.1 + lastsqlen = length2(p1 - pos) + + sqlen = lastsqlen + while 0 <= curper <= 1: + curper += curstep + sqlen = length2(line.pointAtPercent(curper) - pos) + if sqlen > lastsqlen: + curstep *= -0.1 + if abs(sqlen - lastsqlen) < 0.001**2 or abs(curstep) < 1e-7: + break + lastsqlen = sqlen + + return sqrt(sqlen) + + def boundingRect(self) -> QRectF: + outnode, outname = self.output() + innode, inname = self.input() + if outname not in outnode.output_names() or inname not in innode.input_names(): + self.__temporary_invalid = True + return QRectF() + self.__temporary_invalid = False + hlw = self.__line_width + line = self.get_painter_path() + return line.boundingRect().adjusted(-hlw - self.__wire_pick_radius, -hlw, hlw + self.__wire_pick_radius, hlw) + + def shape(self): + # this one is mainly needed for proper selection and item picking + return self.__stroker.createStroke(self.get_painter_path()) + + def get_painter_path(self, close_path=False): + line = QPainterPath() + + outnode, outname = self.output() + innode, inname = self.input() + p0 = outnode.get_output_position(outname) + p1 = innode.get_input_position(inname) + curv = self.__curv + curv = min((p0-p1).manhattanLength()*0.5, curv) + line.moveTo(p0) + line.cubicTo(p0 + QPointF(0, curv), p1 - QPointF(0, curv), p1) + if close_path: + line.cubicTo(p1 - QPointF(0, curv), p0 + QPointF(0, curv), p0) + return line + + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: + if self.__temporary_invalid: + return + if self.__ui_interactor is not None: # if interactor exists - it does all the drawing + return + line = self.get_painter_path() + + painter.setPen(self.__pen) + + if self.__hoverover_pos is not None: + hldiag = QPointF(self.__wire_highlight_radius, self.__wire_highlight_radius) + if line.intersects(QRectF(self.__hoverover_pos - hldiag, self.__hoverover_pos + hldiag)): + painter.setPen(self.__pen_highlight) + + if self.isSelected(): + painter.setPen(self.__thick_pen) + + painter.drawPath(line) + # painter.drawRect(self.boundingRect()) + self.__last_drawn_path = line + + def hoverMoveEvent(self, event): + self.__hoverover_pos = event.pos() + + def hoverLeaveEvent(self, event): + self.__hoverover_pos = None + self.update() + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent): + event.ignore() + if event.button() != Qt.LeftButton: + return + line = self.get_painter_path(close_path=True) + circle = QPainterPath() + circle.addEllipse(event.scenePos(), self.__wire_pick_radius, self.__wire_pick_radius) + if self.__ui_interactor is None and line.intersects(circle): + logger.debug('wire candidate for picking detected') + wgt = event.widget() + if wgt is None: + return + + p = event.scenePos() + outnode, outname = self.output() + innode, inname = self.input() + p0 = outnode.get_output_position(outname) + p1 = innode.get_input_position(inname) + d02 = QPointF.dotProduct(p0 - p, p0 - p) + d12 = QPointF.dotProduct(p1 - p, p1 - p) + if d02 > self.__pick_radius2 and d12 > self.__pick_radius2: # if picked too far from ends - just select + super().mousePressEvent(event) + event.accept() + return + + # this way we report to scene event handler that we are candidates for picking + if hasattr(event, 'wire_candidates'): + event.wire_candidates.append((self.distance_to_point(p), self)) + + def post_mousePressEvent(self, event: QGraphicsSceneMouseEvent): + """ + this will be called by scene as continuation of mousePressEvent + IF scene decides so. + :param event: + :return: + """ + wgt = event.widget().parent() + p = event.scenePos() + outnode, outname = self.output() + innode, inname = self.input() + p0 = outnode.get_output_position(outname) + p1 = innode.get_input_position(inname) + d02 = QPointF.dotProduct(p0 - p, p0 - p) + d12 = QPointF.dotProduct(p1 - p, p1 - p) + + assert isinstance(wgt, GraphicsSceneViewingWidgetBase) + if wgt.request_ui_focus(self): + event.accept() + + output_picked = d02 < d12 + if output_picked: + snap_points = [y for x in self.__scene_container.nodes() if x != innode for y in x.output_snap_points()] + else: + snap_points = [y for x in self.__scene_container.nodes() if x != outnode for y in x.input_snap_points()] + self.__ui_interactor = NodeConnectionCreatePreview(None if output_picked else outnode, + innode if output_picked else None, + outname, inname, + snap_points, 15, self._ui_interactor_finished, True) + self.update() + self.__ui_widget = wgt + self.scene().addItem(self.__ui_interactor) + self.__ui_interactor.mousePressEvent(event) + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: + # if self.__ui_interactor is not None: # redirect input, cuz scene will direct all events to this item. would be better to change focus, but so far scene.setFocusItem did not work as expected + # self.__ui_interactor.mouseMoveEvent(event) + # event.accept() + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: + # event.ignore() + # if event.button() != Qt.LeftButton: + # return + # if self.__ui_interactor is not None: # redirect input, cuz scene will direct all events to this item. would be better to change focus, but so far scene.setFocusItem did not work as expected + # self.__ui_interactor.mouseReleaseEvent(event) + # event.accept() + # self.ungrabMouse() + logger.debug('ungrabbing mouse') + self.ungrabMouse() + super().mouseReleaseEvent(event) + + # _dbg_shitlist = [] + @Slot(object) + def _ui_interactor_finished(self, snap_point: Optional[NodeConnSnapPoint]): + assert self.__ui_interactor is not None + call_later(lambda x: logger.debug(f'later removing {x}') or x.scene().removeItem(x), self.__ui_interactor) + if self.scene() is None: # if scheduler deleted us while interacting + return + # NodeConnection._dbg_shitlist.append(self.__ui_interactor) + self.__ui_widget.release_ui_focus(self) + self.__ui_widget = None + is_cutting = self.__ui_interactor.is_cutting() + self.__ui_interactor = None + self.update() + + # are we cutting the wire + if is_cutting: + self.__data_controller.cut_connection_by_id(self.get_id()) + return + + # actual node reconection + if snap_point is None: + logger.debug('no change') + return + + changing_out = not snap_point.connection_is_input() + self.__data_controller.change_connection_by_id( + self.get_id(), + to_outnode_id=snap_point.node().get_id() if changing_out else None, + to_outname=snap_point.connection_name() if changing_out else None, + to_innode_id=None if changing_out else snap_point.node().get_id(), + to_inname=None if changing_out else snap_point.connection_name() + ) + # scene.request_node_connection_change(self.get_id(), + # snap_point.node().get_id() if changing_out else None, + # snap_point.connection_name() if changing_out else None, + # None if changing_out else snap_point.node().get_id(), + # None if changing_out else snap_point.connection_name()) + + +class SceneTask(Task): + __brushes = None + __borderpen = None + __paused_pen = None + + def __init__(self, scene: GraphicsSceneWithNodesAndTasks, task_data: TaskData, data_controller: SceneDataController): + super().__init__(scene, task_data) + self.__scene_container = scene + self.__data_controller = data_controller + self.setAcceptHoverEvents(True) + self.__hoverover_pos = None + # self.setFlags(QGraphicsItem.ItemIsSelectable) + self.setZValue(1) + self.__layer = 0 # draw layer from 0 - main up to inf. kinda like LOD with highres being 0 + self.__visible_layers_count = 2 + + self.__size = 16 + self.__line_width = 1.5 + + self.__ui_interactor = None + self.__press_pos = None + + self.__animation_group: Optional[QSequentialAnimationGroup] = None + self.__final_pos = None + self.__final_layer = None + + self.__mainshape_cache = None # NOTE: DYNAMIC SIZE OR LINE WIDTH ARE NOT SUPPORTED HERE! + self.__selshape_cache = None + self.__pausedshape_cache = None + self.__bound_cache = None + + self.__requested_invocs_while_selected = set() + + def lerpclr(c1, c2, t): + color = c1 + color.setAlphaF(lerp(color.alphaF(), c2.alphaF(), t)) + color.setRedF(lerp(color.redF(), c2.redF(), t)) + color.setGreenF(lerp(color.greenF(), c2.redF(), t)) + color.setBlueF(lerp(color.blueF(), c2.redF(), t)) + return color + + if self.__borderpen is None: + SceneTask.__borderpen = [QPen(QColor(96, 96, 96, 255), self.__line_width), + QPen(QColor(128, 128, 128, 255), self.__line_width), + QPen(QColor(192, 192, 192, 255), self.__line_width)] + + if self.__brushes is None: + # brushes and paused_pen are precalculated for several layers with different alphas, just not to calc them in paint + def lerp(a, b, t): + return a*(1.0-t) + b*t + + SceneTask.__brushes = { + TaskState.WAITING: QBrush(QColor(64, 64, 64, 192)), + TaskState.GENERATING: QBrush(QColor(32, 128, 128, 192)), + TaskState.READY: QBrush(QColor(32, 64, 32, 192)), + TaskState.INVOKING: QBrush(QColor(108, 108, 12, 192)), + TaskState.IN_PROGRESS: QBrush(QColor(128, 128, 32, 192)), + TaskState.POST_WAITING: QBrush(QColor(96, 96, 96, 192)), + TaskState.POST_GENERATING: QBrush(QColor(128, 32, 128, 192)), + TaskState.DONE: QBrush(QColor(32, 192, 32, 192)), + TaskState.ERROR: QBrush(QColor(192, 32, 32, 192)), + TaskState.SPAWNED: QBrush(QColor(32, 32, 32, 192)), + TaskState.DEAD: QBrush(QColor(16, 19, 22, 192)), + TaskState.SPLITTED: QBrush(QColor(64, 32, 64, 192)), + TaskState.WAITING_BLOCKED: QBrush(QColor(40, 40, 50, 192)), + TaskState.POST_WAITING_BLOCKED: QBrush(QColor(40, 40, 60, 192)) + } + for k, v in SceneTask.__brushes.items(): + ocolor = v.color() + SceneTask.__brushes[k] = [] + for i in range(self.__visible_layers_count): + color = lerpclr(ocolor, QColor.fromRgbF(0, 0, 0, 1), i*1.0/self.__visible_layers_count) + SceneTask.__brushes[k].append(QColor(color)) + if self.__paused_pen is None: + ocolor = QColor(64, 64, 128, 192) + SceneTask.__paused_pen = [] + for i in range(self.__visible_layers_count): + color = lerpclr(ocolor, QColor.fromRgbF(0, 0, 0, 1), i*1.0/self.__visible_layers_count) + SceneTask.__paused_pen.append(QPen(color, self.__line_width*3)) + + def layer_visible(self): + return self.__layer < self.__visible_layers_count + + def boundingRect(self) -> QRectF: + if self.__bound_cache is None: + lw = self.__line_width + self.__bound_cache = QRectF(QPointF(-0.5 * (self.__size + lw), -0.5 * (self.__size + lw)), + QSizeF(self.__size + lw, self.__size + lw)) + return self.__bound_cache + + def _get_mainpath(self) -> QPainterPath: + if self.__mainshape_cache is None: + path = QPainterPath() + path.addEllipse(-0.5 * self.__size, -0.5 * self.__size, + self.__size, self.__size) + self.__mainshape_cache = path + return self.__mainshape_cache + + def _get_selectshapepath(self) -> QPainterPath: + if self.__selshape_cache is None: + path = QPainterPath() + lw = self.__line_width + path.addEllipse(-0.5 * (self.__size + lw), -0.5 * (self.__size + lw), + self.__size + lw, self.__size + lw) + self.__selshape_cache = path + return self.__selshape_cache + + def _get_pausedpath(self) -> QPainterPath: + if self.__pausedshape_cache is None: + path = QPainterPath() + lw = self.__line_width + path.addEllipse(-0.5 * self.__size + 1.5*lw, -0.5 * self.__size + 1.5*lw, + self.__size - 3*lw, self.__size - 3*lw) + self.__pausedshape_cache = path + return self.__pausedshape_cache + + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: + if self.__layer >= self.__visible_layers_count: + return + if self.node() is None: # probably temporary state due to asyncronous incoming events from scheduler + return # or we can draw them somehow else? + screen_rect = painter.worldTransform().mapRect(self.boundingRect()) + + path = self._get_mainpath() + brush = self.__brushes[self.state()][self.__layer] + painter.fillPath(path, brush) + if progress := self.get_progress(): + arcpath = QPainterPath() + arcpath.arcTo(QRectF(-0.5*self.__size, -0.5*self.__size, self.__size, self.__size), + 90, -3.6*progress) + arcpath.closeSubpath() + painter.fillPath(arcpath, self.__brushes[TaskState.DONE][self.__layer]) + if self.paused(): + painter.setPen(self.__paused_pen[self.__layer]) + painter.drawPath(self._get_pausedpath()) + + if screen_rect.width() > 7: + if self.isSelected(): + painter.setPen(self.__borderpen[2]) + elif self.__hoverover_pos is not None: + painter.setPen(self.__borderpen[1]) + else: + painter.setPen(self.__borderpen[0]) + painter.drawPath(path) + + def draw_size(self): + return self.__size + + def set_layer(self, layer: int): + assert layer >= 0 + self.__layer = layer + self.setZValue(1.0/(1.0 + layer)) + + def add_item_watcher(self, watcher: "NetworkItemWatcher"): + super().add_item_watcher(watcher) + # additionally refresh ui if we are not being watched + if len(self.item_watchers()) == 1: # it's a first watcher + self.refresh_ui() + + def set_name(self, name: str): + super().set_name(name) + self.refresh_ui() + + def set_groups(self, groups: Set[str]): + super().set_groups(groups) + self.refresh_ui() + + def refresh_ui(self): + """ + unlike update - this method actually queries new task ui status + if task is not selected or not watched- does nothing + :return: + """ + if not self.isSelected() and len(self.item_watchers()) == 0: + return + self.__data_controller.request_log_meta(self.get_id()) # update all task metadata: which nodes it ran on and invocation numbers only + self.__data_controller.request_attributes(self.get_id()) + + for invoc_id, nid, invoc_dict in self.invocation_logs(): + if invoc_dict is None: + continue + if (isinstance(invoc_dict, IncompleteInvocationLogData) + or invoc_dict.invocation_state != InvocationState.FINISHED) and invoc_id in self.__requested_invocs_while_selected: + self.__requested_invocs_while_selected.remove(invoc_id) + + def final_location(self) -> (Node, QPointF): + if self.__animation_group is not None: + assert self.__final_pos is not None + return self.node(), self.__final_pos + else: + return self.node(), self.pos() + + def final_scene_position(self) -> QPointF: + fnode, fpos = self.final_location() + if fnode is not None: + fpos = fnode.mapToScene(fpos) + return fpos + + def is_in_animation(self): + return self.__animation_group is not None + + @Slot() + def _clear_animation_group(self): + if self.__animation_group is not None: + ag, self.__animation_group = self.__animation_group, None + ag.stop() # just in case some recursion occures + ag.deleteLater() + self.setParentItem(self.node()) + self.setPos(self.__final_pos) + self.set_layer(self.__final_layer) + self.__final_pos = None + self.__final_layer = None + + def set_task_position(self, node: Node, pos: QPointF, layer: int): + """ + set task position to given node and give pos/layer inside that node + also cancels any active move animation + """ + if self.__animation_group is not None: + self.__animation_group.stop() + self.__animation_group.deleteLater() + self.__animation_group = None + + self.setParentItem(node) + if pos is not None: + self.setPos(pos) + if layer is not None: + self.set_layer(layer) + + def append_task_move_animation(self, node: Node, pos: QPointF, layer: int): + """ + set task position to given node and give pos/layer inside that node, + but do it with animation + """ + # first try to optimize, if we move on the same node to invisible layer - don't animate + if node == self.node() and layer >= self.__visible_layers_count and self.__animation_group is None: + return self.set_task_position(node, pos, layer) + + # + dist = ((pos if node is None else node.mapToScene(pos)) - self.final_scene_position()) + ldist = sqrt(QPointF.dotProduct(dist, dist)) + self.set_layer(0) + animgroup = self.__animation_group + if animgroup is None: + animgroup = QSequentialAnimationGroup(self.scene()) + animgroup.finished.connect(self._clear_animation_group) + anim_speed = max(1.0, animgroup.animationCount() - 2) # -2 to start speedup only after a couple anims in queue + start_node, start_pos = self.final_location() + new_animation = TaskAnimation(self, start_node, start_pos, node, pos, duration=max(1, int(ldist / anim_speed)), parent=animgroup) + if self.__animation_group is None: + self.setParentItem(None) + self.__animation_group = animgroup + + self.__final_pos = pos + self.__final_layer = layer + # turns out i do NOT need to add animation to group IF animgroup was passed as parent to animation - it's added automatically + # self.__animation_group.addAnimation(new_animation) + if self.__animation_group.state() != QAbstractAnimation.Running: + self.__animation_group.start() + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemSelectedHasChanged: + if value and self.node() is not None: # item was just selected + self.refresh_ui() + elif not value: + self.setFlag(QGraphicsItem.ItemIsSelectable, False) # we are not selectable any more by band selection until directly clicked + pass + + elif change == QGraphicsItem.ItemSceneChange: + if value is None: # removing item from scene + if self.__animation_group is not None: + self.__animation_group.stop() + self.__animation_group.clear() + self.__animation_group.deleteLater() + self.__animation_group = None + if self.node() is not None: + self.node().remove_task(self) + return super().itemChange(change, value) # TODO: maybe move this to scene's remove item? + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: + if not self._get_selectshapepath().contains(event.pos()): + event.ignore() + return + self.setFlag(QGraphicsItem.ItemIsSelectable, True) # if we are clicked - we are now selectable until unselected. This is to avoid band selection + super().mousePressEvent(event) + self.__press_pos = event.scenePos() + + if event.button() == Qt.RightButton: + # context menu time + view = event.widget().parent() + assert isinstance(view, GraphicsSceneViewingWidgetBase) + view.item_requests_context_menu(self) + event.accept() + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: + if self.__ui_interactor is None: + movedist = event.scenePos() - self.__press_pos + if QPointF.dotProduct(movedist, movedist) > 2500: # TODO: config this rad squared + self.__ui_interactor = TaskPreview(self) + self.scene().addItem(self.__ui_interactor) + if self.__ui_interactor: + self.__ui_interactor.mouseMoveEvent(event) + else: + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: + if self.__ui_interactor: + self.__ui_interactor.mouseReleaseEvent(event) + nodes = [x for x in self.scene().items(event.scenePos(), Qt.IntersectsItemBoundingRect) if isinstance(x, Node)] # TODO: dirty, implement such method in one of scene subclasses + if len(nodes) > 0: + logger.debug(f'moving item {self} to node {nodes[0]}') + self.__data_controller.request_set_task_node(self.get_id(), nodes[0].get_id()) + call_later(self.__ui_interactor.scene().removeItem, self.__ui_interactor) + self.__ui_interactor = None + + else: + super().mouseReleaseEvent(event) + + def hoverMoveEvent(self, event): + self.__hoverover_pos = event.pos() + + def hoverLeaveEvent(self, event): + self.__hoverover_pos = None + self.update() + + @staticmethod + def _draw_dict_table(attributes: dict, table_name: str): + imgui.columns(2, table_name) + imgui.separator() + imgui.text('name') + imgui.next_column() + imgui.text('value') + imgui.next_column() + imgui.separator() + for key, val in attributes.items(): + imgui.text(key) + imgui.next_column() + imgui.text(repr(val)) + imgui.next_column() + imgui.columns(1) + + # + # interface + def draw_imgui_elements(self, drawing_widget): + imgui.text(f'Task {self.get_id()} {self.name()}') + imgui.text(f'state: {self.state().name}') + imgui.text(f'groups: {", ".join(self.groups())}') + imgui.text(f'parent id: {self.parent_task_id()}') + imgui.text(f'children count: {self.children_tasks_count()}') + imgui.text(f'split level: {self.split_level()}') + imgui.text(f'invocation attempts: {self.latest_invocation_attempt()}') + + # first draw attributes + if self.attributes(): + self._draw_dict_table(self.attributes(), 'node_task_attributes') + + if env_res_args := self.environment_attributes(): + tab_expanded, _ = imgui.collapsing_header(f'environment resolver attributes##collapsing_node_task_environment_resolver_attributes') + if tab_expanded: + imgui.text(f'environment resolver: "{env_res_args.name()}"') + if env_res_args.arguments(): + self._draw_dict_table(env_res_args.arguments(), 'node_task_environment_resolver_attributes') + + # now draw log + imgui.text('Logs:') + for node_id, invocs in self.invocation_logs_mapping().items(): + node: Node = self.__scene_container.get_node(node_id) + if node is None: + logger.warning(f'node for task {self.get_id()} does not exist') + continue + node_name: str = node.node_name() + node_expanded, _ = imgui.collapsing_header(f'node {node_id}' + (f' "{node_name}"' if node_name else '')) + if not node_expanded: # or invocs is None: + continue + for invoc_id, invoc_log in invocs.items(): + # TODO: pyimgui is not covering a bunch of fancy functions... watch when it's done + imgui.indent(10) + invoc_expanded, _ = imgui.collapsing_header(f'invocation {invoc_id}' + + (f', worker {invoc_log.worker_id}' if isinstance(invoc_log, InvocationLogData) is not None else '') + + f', time: {timedelta(seconds=round(invoc_log.invocation_runtime)) if invoc_log.invocation_runtime is not None else "N/A"}' + + f'###logentry_{invoc_id}') + if not invoc_expanded: + imgui.unindent(10) + continue + if invoc_id not in self.__requested_invocs_while_selected: + self.__requested_invocs_while_selected.add(invoc_id) + self.__data_controller.request_log(invoc_id) + if isinstance(invoc_log, IncompleteInvocationLogData): + imgui.text('...fetching...') + else: + if invoc_log.stdout: + if imgui.button(f'open in viewer##{invoc_id}'): + fetch_and_open_log_viewer(self.scene(), invoc_id, drawing_widget, update_interval=None if invoc_log.invocation_state == InvocationState.FINISHED else 5) + + imgui.text_unformatted(invoc_log.stdout or '...nothing here...') + if invoc_log.invocation_state == InvocationState.IN_PROGRESS: + if imgui.button('update'): + logger.debug('clicked') + if invoc_id in self.__requested_invocs_while_selected: + self.__requested_invocs_while_selected.remove(invoc_id) + imgui.unindent(10) + + +class NodeConnectionCreatePreview(QGraphicsItem): + def __init__(self, nodeout: Optional[Node], nodein: Optional[Node], outname: str, inname: str, snap_points: List[NodeConnSnapPoint], snap_radius: float, report_done_here: Callable, do_cutting: bool = False): + super().__init__() + assert nodeout is None and nodein is not None or \ + nodeout is not None and nodein is None + self.setFlags(QGraphicsItem.ItemSendsGeometryChanges) + self.setZValue(10) + self.__nodeout = nodeout + self.__nodein = nodein + self.__outname = outname + self.__inname = inname + self.__snappoints = snap_points + self.__snap_radius2 = snap_radius * snap_radius + self.setZValue(-1) + self.__line_width = 4 + self.__curv = 150 + self.__breakdist2 = 200**2 + + self.__ui_last_pos = QPointF() + self.__finished_callback = report_done_here + + self.__pen = QPen(QColor(64, 64, 64, 192)) + self.__pen.setWidthF(3) + + self.__do_cutting = do_cutting + self.__cutpen = QPen(QColor(96, 32, 32, 192)) + self.__cutpen.setWidthF(3) + self.__cutpen.setStyle(Qt.DotLine) + + self.__is_snapping = False + + self.__orig_pos: Optional[QPointF] = None + + def get_painter_path(self): + if self.__nodein is not None: + p0 = self.__ui_last_pos + p1 = self.__nodein.get_input_position(self.__inname) + else: + p0 = self.__nodeout.get_output_position(self.__outname) + p1 = self.__ui_last_pos + + curv = self.__curv + curv = min((p0 - p1).manhattanLength() * 0.5, curv) + + line = QPainterPath() + line.moveTo(p0) + line.cubicTo(p0 + QPointF(0, curv), p1 - QPointF(0, curv), p1) + return line + + def boundingRect(self) -> QRectF: + hlw = self.__line_width + + if self.__nodein is not None: + inputpos = self.__ui_last_pos + outputpos = self.__nodein.get_input_position(self.__inname) + else: + inputpos = self.__nodeout.get_output_position(self.__outname) + outputpos = self.__ui_last_pos + + return QRectF(QPointF(min(inputpos.x(), outputpos.x()) - hlw, min(inputpos.y(), outputpos.y()) - hlw), + QPointF(max(inputpos.x(), outputpos.x()) + hlw, max(inputpos.y(), outputpos.y()) + hlw)) + + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: + line = self.get_painter_path() + if self.is_cutting(): + painter.setPen(self.__cutpen) + else: + painter.setPen(self.__pen) + painter.drawPath(line) + # painter.drawRect(self.boundingRect()) + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent): + if event.button() != Qt.LeftButton: + event.ignore() + return + self.grabMouse() + pos = event.scenePos() + closest_snap = self.get_closest_snappoint(pos) + self.__is_snapping = False + if closest_snap is not None: + pos = closest_snap.pos() + self.__is_snapping = True + self.prepareGeometryChange() + self.__ui_last_pos = pos + if self.__orig_pos is None: + self.__orig_pos = pos + event.accept() + + def mouseMoveEvent(self, event): + pos = event.scenePos() + closest_snap = self.get_closest_snappoint(pos) + self.__is_snapping = False + if closest_snap is not None: + pos = closest_snap.pos() + self.__is_snapping = True + self.prepareGeometryChange() + self.__ui_last_pos = pos + if self.__orig_pos is None: + self.__orig_pos = pos + event.accept() + + def is_cutting(self): + """ + wether or not interactor is it cutting the wire state + :return: + """ + return self.__do_cutting and not self.__is_snapping and self.__orig_pos is not None and length2(self.__orig_pos - self.__ui_last_pos) > self.__breakdist2 + + def get_closest_snappoint(self, pos: QPointF) -> Optional[NodeConnSnapPoint]: + snappoints = [x for x in self.__snappoints if length2(x.pos() - pos) < self.__snap_radius2] + + if len(snappoints) == 0: + return None + + return min(snappoints, key=lambda x: length2(x.pos() - pos)) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): + if event.button() != Qt.LeftButton: + event.ignore() + return + if self.__finished_callback is not None: + self.__finished_callback(self.get_closest_snappoint(event.scenePos())) + event.accept() + self.ungrabMouse() + + +class TaskPreview(QGraphicsItem): + def __init__(self, task: Task): + super().__init__() + self.setZValue(10) + self.__size = 16 + self.__line_width = 1.5 + self.__finished_callback = None + self.setZValue(10) + + self.__borderpen = QPen(QColor(192, 192, 192, 255), self.__line_width) + self.__brush = QBrush(QColor(64, 64, 64, 128)) + + def boundingRect(self) -> QRectF: + lw = self.__line_width + return QRectF(QPointF(-0.5 * (self.__size + lw), -0.5 * (self.__size + lw)), + QSizeF(self.__size + lw, self.__size + lw)) + + def _get_mainpath(self) -> QPainterPath: + path = QPainterPath() + path.addEllipse(-0.5 * self.__size, -0.5 * self.__size, + self.__size, self.__size) + return path + + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: + path = self._get_mainpath() + brush = self.__brush + painter.fillPath(path, brush) + painter.setPen(self.__borderpen) + painter.drawPath(path) + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: + self.setPos(event.scenePos()) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): + if self.__finished_callback is not None: + self.__finished_callback(event.scenePos()) # not used for now not to overcomplicate + event.accept() diff --git a/src/lifeblood_viewer/graphics_items/graphics_items.py b/src/lifeblood_viewer/graphics_items/graphics_items.py index 30525e14..516895b1 100644 --- a/src/lifeblood_viewer/graphics_items/graphics_items.py +++ b/src/lifeblood_viewer/graphics_items/graphics_items.py @@ -1,98 +1,61 @@ import json -import itertools from enum import Enum -from math import sqrt from types import MappingProxyType -from datetime import timedelta -from ..code_editor.editor import StringParameterEditor -from .node_extra_items import ImplicitSplitVisualizer + from .network_item import NetworkItemWithUI, NetworkItem from .network_item_watchers import NetworkItemWatcher, WatchableNetworkItem, WatchableNetworkItemProxy +from ..graphics_scene_base import GraphicsSceneBase -from lifeblood.config import get_config -from lifeblood.uidata import NodeUi, Parameter, ParameterExpressionError, ParametersLayoutBase, OneLineParametersLayout, CollapsableVerticalGroup, Separator, MultiGroupLayout +from lifeblood.uidata import NodeUi from lifeblood.ui_protocol_data import TaskData, TaskDelta, DataNotSet, IncompleteInvocationLogData, InvocationLogData from lifeblood.basenode import BaseNode -from lifeblood.enums import TaskState, InvocationState +from lifeblood.enums import TaskState from lifeblood import logging from lifeblood.environment_resolver import EnvironmentResolverArguments -from lifeblood.enums import NodeParameterType - -import PySide2.QtGui -from PySide2.QtWidgets import * -from PySide2.QtCore import Qt, Slot, QRectF, QSizeF, QPointF, QAbstractAnimation, QSequentialAnimationGroup -from PySide2.QtGui import QPen, QBrush, QColor, QPainterPath, QPainterPathStroker, QKeyEvent, QLinearGradient, QDesktopServices - -import imgui +from PySide2.QtWidgets import QGraphicsScene, QGraphicsItem -from typing import FrozenSet, TYPE_CHECKING, Optional, List, Tuple, Dict, Set, Callable, Iterable, Union +from typing import FrozenSet, Optional, List, Tuple, Dict, Set, Callable, Iterable, Union -from .. import nodeeditor # TODO: break dep loop -from ..editor_scene_integration import fetch_and_open_log_viewer -if TYPE_CHECKING: - from ..graphics_scene import QGraphicsImguiScene logger = logging.get_logger('viewer') -def call_later(callable, *args, **kwargs): #TODO: this repeats here and in nodeeditor - if len(args) == 0 and len(kwargs) == 0: - PySide2.QtCore.QTimer.singleShot(0, callable) - else: - PySide2.QtCore.QTimer.singleShot(0, lambda: callable(*args, **kwargs)) - - -def length2(v: QPointF): - return QPointF.dotProduct(v, v) - +class SceneNetworkItem(NetworkItem): + def __init__(self, scene: GraphicsSceneBase, id: int): + super().__init__(id) + self.__scene = scene -class TaskAnimation(QAbstractAnimation): - def __init__(self, task: "Task", node1: "Node", pos1: "QPointF", node2: "Node", pos2: "QPointF", duration: int, parent): - super(TaskAnimation, self).__init__(parent) - self.__task = task + def graphics_scene(self) -> GraphicsSceneBase: + return self.__scene - self.__node1 = node1 - self.__pos1 = pos1 - self.__node2 = node2 - self.__pos2 = pos2 - self.__duration = max(duration, 1) - self.__started = False - self.__anim_type = 0 if self.__node1 is self.__node2 else 1 - - def duration(self) -> int: - return self.__duration + def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value): + if change == QGraphicsItem.ItemSceneChange: # just before scene change + if self.scene() is not None and value is not None: + raise RuntimeError('changing scenes is not supported') + return super().itemChange(change, value) - def updateCurrentTime(self, currentTime: int) -> None: - if not self.__started: - self.__started = True - pos1 = self.__pos1 - if self.__node1: - pos1 = self.__node1.mapToScene(pos1) +class SceneNetworkItemWithUI(NetworkItemWithUI): + def __init__(self, scene: GraphicsSceneBase, id: int): + super().__init__(id) + self.__scene = scene - pos2 = self.__pos2 - if self.__node2: - pos2 = self.__node2.mapToScene(pos2) + def graphics_scene(self) -> GraphicsSceneBase: + return self.__scene - t = currentTime / self.duration() - if self.__anim_type == 0: # linear - pos = pos1 * (1 - t) + pos2 * t - else: # cubic - curv = min((pos2-pos1).manhattanLength() * 2, 1000) # 1000 is kinda derivative - a = QPointF(0, curv) - (pos2-pos1) - b = QPointF(0, -curv) + (pos2-pos1) - pos = pos1*(1-t) + pos2*t + t*(1-t)*(a*(1-t) + b*t) - self.__task.setPos(pos) + def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value): + if change == QGraphicsItem.ItemSceneChange: # just before scene change + if self.scene() is not None and value is not None: + raise RuntimeError('changing scenes is not supported') + return super().itemChange(change, value) -class Node(NetworkItemWithUI, WatchableNetworkItemProxy): +class Node(SceneNetworkItemWithUI, WatchableNetworkItemProxy): class TaskSortOrder(Enum): ID = 0 - base_height = 100 - base_width = 150 # cache node type-2-inputs/outputs names, not to ask a million times for every node # actually this can be dynamic, and this cache is not used anyway, so TODO: get rid of it? _node_inputs_outputs_cached: Dict[str, Tuple[List[str], List[str]]] = {} @@ -109,85 +72,27 @@ def _ui_changed(self, definition_changed=False): if definition_changed: self.__my_node.reanalyze_nodeui() - def __init__(self, id: int, type: str, name: str): - super(Node, self).__init__(id) - self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemSendsGeometryChanges) - self.setAcceptHoverEvents(True) - self.__hoverover_pos: Optional[QPointF] = None - self.__height = self.base_height - self.__width = self.base_width - self.__pivot_x = 0 - self.__pivot_y = 0 - - self.__input_radius = 12 - self.__input_visible_radius = 8 - self.__line_width = 1 + def __init__(self, scene: GraphicsSceneBase, id: int, type: str, name: str): + super().__init__(scene, id) + self.__name = name - self.__tasks: List["Task"] = [] + self.__tasks: Set["Task"] = set() self.__tasks_sorted_cached: Optional[Dict[Node.TaskSortOrder, List["Task"]]] = None self.__node_type = type - self.__ui_interactor = None - self.__ui_widget: Optional[nodeeditor.NodeEditor] = None - self.__ui_grabbed_conn = None - - self.__ui_selected_tab = 0 - self.__move_start_position = None - self.__move_start_selection = None - - # prepare default drawing tools - self.__borderpen= QPen(QColor(96, 96, 96, 255)) - self.__borderpen_selected = QPen(QColor(144, 144, 144, 255)) - self.__caption_pen = QPen(QColor(192, 192, 192, 255)) - self.__typename_pen = QPen(QColor(128, 128, 128, 192)) - self.__borderpen.setWidthF(self.__line_width) - self.__header_brush = QBrush(QColor(48, 64, 48, 192)) - self.__body_brush = QBrush(QColor(48, 48, 48, 128)) - self.__connector_brush = QBrush(QColor(48, 48, 48, 192)) - self.__connector_brush_hovered = QBrush(QColor(96, 96, 96, 128)) - self.__nodeui: Optional[NodeUi] = None - self.__nodeui_menucache = {} self.__connections: Set[NodeConnection] = set() - self.__expanded = False - - self.__cached_bounds = None - self.__cached_nodeshape = None - self.__cached_bodymask = None - self.__cached_headershape = None - self.__cached_bodyshape = None - self.__cached_expandbutton_shape = None self.__inputs, self.__outputs = None, None - self.__node_ui_for_io_requested = False if self.__node_type in Node._node_inputs_outputs_cached: self.__inputs, self.__outputs = Node._node_inputs_outputs_cached[self.__node_type] - # children! - self.__vismark = ImplicitSplitVisualizer(self) - self.__vismark.setPos(QPointF(0, self._get_nodeshape().boundingRect().height() * 0.5)) - self.__vismark.setZValue(-2) - - # misc - self.__manual_url_base = get_config('viewer').get_option_noasync('manual_base_url', 'https://pedohorse.github.io/lifeblood') - def get_session_id(self): """ session id is local id that should be preserved within a session even after undo/redo operations, unlike simple id, that will change on undo/redo """ - return self.scene()._session_node_id_from_id(self.get_id()) - - def prepareGeometryChange(self): - super(Node, self).prepareGeometryChange() - self.__cached_bounds = None - self.__cached_nodeshape = None - self.__cached_bodymask = None - self.__cached_headershape = None - self.__cached_bodyshape = None - self.__cached_expandbutton_shape = None - for conn in self.__connections: - conn.prepareGeometryChange() + return self.graphics_scene()._session_node_id_from_id(self.get_id()) def node_type(self) -> str: return self.__node_type @@ -202,103 +107,26 @@ def set_name(self, new_name: str): self.item_updated(redraw=True, ui=True) def set_selected(self, selected: bool, *, unselect_others=False): - scene: QGraphicsImguiScene = self.scene() + scene: QGraphicsScene = self.graphics_scene() if unselect_others: scene.clearSelection() self.setSelected(selected) - def apply_settings(self, settings_name: str): - scene: QGraphicsImguiScene = self.scene() - scene.request_apply_node_settings(self.get_id(), settings_name) - - def pause_all_tasks(self): - scene: QGraphicsImguiScene = self.scene() - scene.set_tasks_paused([x.get_id() for x in self.__tasks], True) - - def resume_all_tasks(self): - scene: QGraphicsImguiScene = self.scene() - scene.set_tasks_paused([x.get_id() for x in self.__tasks], False) - - def regenerate_all_ready_tasks(self): - """ - all currently displayed tasks that are in states BEFORE processing, will be set to WAITING - """ - self._change_all_task_states((TaskState.READY, TaskState.WAITING_BLOCKED), TaskState.WAITING) - - def retry_all_error_tasks(self): - """ - all currently displayed task that are in ERROR state will be reset to WAITING - """ - self._change_all_task_states((TaskState.ERROR,), TaskState.WAITING) - - def _change_all_task_states(self, from_states: Tuple[TaskState, ...], to_state: TaskState): - scene: QGraphicsImguiScene = self.scene() - scene.set_task_state( - [x.get_id() for x in self.__tasks if x.state() in from_states], - to_state - ) - def update_nodeui(self, nodeui: NodeUi): self.__nodeui = nodeui - self.__nodeui_menucache = {} self.__nodeui.attach_to_node(Node.PseudoNode(self)) self.reanalyze_nodeui() def reanalyze_nodeui(self): - self.prepareGeometryChange() # not calling this seem to be able to break scene's internal index info on our connections - # bug that appears - on first scene load deleting a node with more than 1 input/output leads to crash - # on open nodes have 1 output, then they receive interface update and this func is called, and here's where bug may happen - Node._node_inputs_outputs_cached[self.__node_type] = (list(self.__nodeui.inputs_names()), list(self.__nodeui.outputs_names())) self.__inputs, self.__outputs = Node._node_inputs_outputs_cached[self.__node_type] - css = self.__nodeui.color_scheme() - if css.secondary_color() is not None: - gradient = QLinearGradient(-self.__width*0.1, 0, self.__width*0.1, 16) - gradient.setColorAt(0.0, QColor(*(x * 255 for x in css.main_color()), 192)) - gradient.setColorAt(1.0, QColor(*(x * 255 for x in css.secondary_color()), 192)) - self.__header_brush = QBrush(gradient) - else: - self.__header_brush = QBrush(QColor(*(x * 255 for x in css.main_color()), 192)) self.item_updated(redraw=True, ui=True) # cuz input count affects visualization in the graph def get_nodeui(self) -> Optional[NodeUi]: return self.__nodeui - def set_expanded(self, expanded: bool): - if self.__expanded == expanded: - return - self.__expanded = expanded - self.prepareGeometryChange() - self.__height = self.base_height - if expanded: - self.__height += 225 - self.__pivot_y -= 225/2 - # self.setPos(self.pos() + QPointF(0, 225*0.5)) - else: - self.__pivot_y = 0 - # self.setPos(self.pos() - QPointF(0, 225 * 0.5)) # TODO: modify painterpath getters to avoid moving nodes on expand - self.__vismark.setPos(QPointF(0, self._get_nodeshape().boundingRect().height() * 0.5)) - - for i, task in enumerate(self.__tasks): - self.__make_task_child_with_position(task, *self.get_task_pos(task, i), animate=True) - - def input_snap_points(self): - # TODO: cache snap points, don't recalc them every time - if self.__nodeui is None: - return [] - inputs = [] - for input_name in self.__nodeui.inputs_names(): - inputs.append(NodeConnSnapPoint(self, input_name, True)) - return inputs - - def output_snap_points(self): - # TODO: cache snap points, don't recalc them every time - if self.__nodeui is None: - return [] - outputs = [] - for output_name in self.__nodeui.outputs_names(): - outputs.append(NodeConnSnapPoint(self, output_name, False)) - return outputs + def all_connections(self) -> FrozenSet["NodeConnection"]: + return frozenset(self.__connections) def input_connections(self, inname) -> Set["NodeConnection"]: if self.__inputs is not None and inname not in self.__inputs: @@ -310,11 +138,11 @@ def output_connections(self, outname) -> Set["NodeConnection"]: raise RuntimeError(f'nodetype {self.__node_type} does not have output {outname}') return {x for x in self.__connections if x.output() == (self, outname)} - def input_names(self) -> Set[str]: - return self.__inputs or set() + def input_names(self) -> Tuple[str]: + return tuple(self.__inputs) if self.__inputs else () - def output_names(self) -> Set[str]: - return self.__outputs or set() + def output_names(self) -> tuple[str]: + return tuple(self.__outputs) if self.__outputs else () def input_nodes(self, inname: Optional[str] = None) -> Set["Node"]: """ @@ -360,192 +188,19 @@ def remove_item_watcher(self, watcher: "NetworkItemWatcher"): for task in self.__tasks: task.remove_item_watcher(self) - def boundingRect(self) -> QRectF: - if self.__cached_bounds is None: - lw = self.__width + self.__line_width - lh = self.__height + self.__line_width - self.__cached_bounds = QRectF( - -0.5 * lw - self.__pivot_x, - -0.5 * lh - (max(self.__input_radius, self.__input_visible_radius) + 0.5 * self.__line_width) - self.__pivot_y, - lw, - lh + 2 * (max(self.__input_radius, self.__input_visible_radius) + 0.5 * self.__line_width)) - return self.__cached_bounds - - def _get_nodeshape(self): - if self.__cached_nodeshape is None: - lw = self.__width + self.__line_width - lh = self.__height + self.__line_width - nodeshape = QPainterPath() - nodeshape.addRoundedRect(QRectF(-0.5 * lw - self.__pivot_x, -0.5 * lh - self.__pivot_y, lw, lh), 5, 5) - self.__cached_nodeshape = nodeshape - return self.__cached_nodeshape - - def _get_bodymask(self): - if self.__cached_bodymask is None: - lw = self.__width + self.__line_width - lh = self.__height + self.__line_width - bodymask = QPainterPath() - bodymask.addRect(-0.5 * lw - self.__pivot_x, -0.5 * lh + 32 - self.__pivot_y, lw, lh - 32) - self.__cached_bodymask = bodymask - return self.__cached_bodymask - - def _get_headershape(self): - if self.__cached_headershape is None: - self.__cached_headershape = self._get_nodeshape() - self._get_bodymask() - return self.__cached_headershape - - def _get_bodyshape(self): - if self.__cached_bodyshape is None: - self.__cached_bodyshape = self._get_nodeshape() & self._get_bodymask() - return self.__cached_bodyshape - - def _get_expandbutton_shape(self): - if self.__cached_expandbutton_shape is None: - bodyshape = self._get_bodyshape() - mask = QPainterPath() - body_bound = bodyshape.boundingRect() - corner = body_bound.bottomRight() + QPointF(15, 15) - top = corner + QPointF(0, -60) - left = corner + QPointF(-60, 0) - mask.moveTo(corner) - mask.lineTo(top) - mask.lineTo(left) - mask.lineTo(corner) - self.__cached_expandbutton_shape = bodyshape & mask - return self.__cached_expandbutton_shape - - def paint(self, painter: PySide2.QtGui.QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: - screen_rect = painter.worldTransform().mapRect(self.boundingRect()) - painter.pen().setWidthF(self.__line_width) - nodeshape = self._get_nodeshape() - - if not self.__node_ui_for_io_requested: - assert self.scene() is not None - self.__node_ui_for_io_requested = True - self.scene().request_node_ui(self.get_id()) - - if screen_rect.width() > 40 and self.__inputs is not None and self.__outputs is not None: - ninputs = len(self.__inputs) - noutputs = len(self.__outputs) - r2 = (self.__input_radius + 0.5*self.__line_width)**2 - for fi in range(ninputs + noutputs): - path = QPainterPath() - is_inputs = fi < ninputs - i = fi if is_inputs else fi - ninputs - input_point = QPointF(-0.5 * self.__width + (i + 1) * self.__width/((ninputs if is_inputs else noutputs) + 1) - self.__pivot_x, - (-0.5 if is_inputs else 0.5) * self.__height - self.__pivot_y) - path.addEllipse(input_point, - self.__input_visible_radius, self.__input_visible_radius) - path -= nodeshape - pen = self.__borderpen - brush = self.__connector_brush - if self.__hoverover_pos is not None: - if QPointF.dotProduct(input_point - self.__hoverover_pos, input_point - self.__hoverover_pos) <= r2: - pen = self.__borderpen_selected - brush = self.__connector_brush_hovered - painter.setPen(pen) - painter.fillPath(path, brush) - painter.drawPath(path) - - headershape = self._get_headershape() - bodyshape = self._get_bodyshape() - - if self.isSelected(): - if screen_rect.width() > 100: - width_mult = 1 - elif screen_rect.width() > 50: - width_mult = 4 - elif screen_rect.width() > 25: - width_mult = 8 - else: - width_mult = 16 - self.__borderpen_selected.setWidth(self.__line_width*width_mult) - painter.setPen(self.__borderpen_selected) - else: - painter.setPen(self.__borderpen) - painter.fillPath(headershape, self.__header_brush) - painter.fillPath(bodyshape, self.__body_brush) - expand_button_shape = self._get_expandbutton_shape() - painter.fillPath(expand_button_shape, self.__header_brush) - painter.drawPath(nodeshape) - # draw highlighted elements on top - if self.__hoverover_pos and expand_button_shape.contains(self.__hoverover_pos): - painter.setPen(self.__borderpen_selected) - painter.drawPath(expand_button_shape) - - # draw header/text last - if screen_rect.width() > 50: - painter.setPen(self.__caption_pen) - painter.drawText(headershape.boundingRect(), Qt.AlignHCenter | Qt.AlignTop, self.__name) - painter.setPen(self.__typename_pen) - painter.drawText(headershape.boundingRect(), Qt.AlignRight | Qt.AlignBottom, self.__node_type) - painter.drawText(headershape.boundingRect(), Qt.AlignLeft | Qt.AlignBottom, f'{len(self.__tasks)}') - - def get_input_position(self, name: str = 'main') -> QPointF: - if self.__inputs is None: - idx = 0 - cnt = 1 - elif name not in self.__inputs: - raise RuntimeError(f'unexpected input name {name}') - else: - idx = self.__inputs.index(name) - cnt = len(self.__inputs) - assert cnt > 0 - return self.mapToScene(-0.5 * self.__width + (idx + 1) * self.__width/(cnt + 1) - self.__pivot_x, - -0.5 * self.__height - self.__pivot_y) - - def get_output_position(self, name: str = 'main') -> QPointF: - if self.__outputs is None: - idx = 0 - cnt = 1 - elif name not in self.__outputs: - raise RuntimeError(f'unexpected output name {name} , {self.__outputs}') - else: - idx = self.__outputs.index(name) - cnt = len(self.__outputs) - assert cnt > 0 - return self.mapToScene(-0.5 * self.__width + (idx + 1) * self.__width/(cnt + 1) - self.__pivot_x, - 0.5 * self.__height - self.__pivot_y) - - def __make_task_child_with_position(self, task: "Task", pos: QPointF, layer: int, *, animate: bool = False): - """ - helper function that actually changes parent of a task and initializes animations if needed - """ - if animate: - task.append_task_move_animation(self, pos, layer) - else: - task.set_task_position(self, pos, layer) - + def add_task(self, task: "Task"): + if task in self.__tasks: + return + logger.debug(f"adding task {task.get_id()} to node {self.get_id()}") need_ui_update = self != task.node() if task.node() and task.node() != self: task.node().remove_task(task) task._set_parent_node(self) - + self.__tasks.add(task) if need_ui_update: task.item_updated(redraw=False, ui=True) - def add_task(self, task: "Task", animated=True): - if task in self.__tasks: - return - logger.debug(f"adding task {task.get_id()} to node {self.get_id()}") self.item_updated(redraw=True, ui=False) # cuz node displays task number - we should redraw - pos_id = len(self.__tasks) - if task.node() is None or not animated: - self.__make_task_child_with_position(task, *self.get_task_pos(task, pos_id)) - else: - self.__make_task_child_with_position(task, *self.get_task_pos(task, pos_id), animate=True) - - insert_at = self._find_insert_index_for_task(task, prefer_back=True) - - # invalidate sorted cache - self.__tasks_sorted_cached = None - - self.__tasks.append(None) # temporary placeholder, it'll be eliminated either in the loop, or after if task is last - for i in reversed(range(insert_at + 1, len(self.__tasks))): - self.__tasks[i] = self.__tasks[i-1] # TODO: animated param should affect below! - self.__make_task_child_with_position(self.__tasks[i], *self.get_task_pos(self.__tasks[i], i), animate=True) - self.__tasks[insert_at] = task - self.__make_task_child_with_position(self.__tasks[insert_at], *self.get_task_pos(task, insert_at), animate=True) if len(self.item_watchers()) > 0: task.add_item_watcher(self) @@ -561,39 +216,24 @@ def remove_tasks(self, tasks_to_remove: Iterable["Task"]): task._set_parent_node(None) if len(self.item_watchers()) > 0: task.remove_item_watcher(self) + for task in tasks_to_remove: + self.__tasks.remove(task) # invalidate sorted cache self.__tasks_sorted_cached = None - if self.__tasks is tasks_to_remove: # special case - self.__tasks = [] - else: - self.__tasks: List["Task"] = [None if x in tasks_to_remove else x for x in self.__tasks] - off = 0 - for i, task in enumerate(self.__tasks): - if task is None: - off += 1 - else: - self.__tasks[i - off] = self.__tasks[i] - self.__make_task_child_with_position(self.__tasks[i - off], *self.get_task_pos(self.__tasks[i - off], i - off), animate=True) - self.__tasks = self.__tasks[:-off] - for x in tasks_to_remove: - assert x not in self.__tasks self.item_updated(redraw=True, ui=False) # cuz node displays task number - we should redraw def remove_task(self, task_to_remove: "Task"): logger.debug(f"removing task {task_to_remove.get_id()} from node {self.get_id()}") - task_pid = self.__tasks.index(task_to_remove) + task_to_remove._set_parent_node(None) + if len(self.item_watchers()) > 0: + task_to_remove.remove_item_watcher(self) # invalidate sorted cache self.__tasks_sorted_cached = None - task_to_remove._set_parent_node(None) - for i in range(task_pid, len(self.__tasks) - 1): - self.__tasks[i] = self.__tasks[i + 1] - self.__make_task_child_with_position(self.__tasks[i], *self.get_task_pos(self.__tasks[i], i), animate=True) - self.__tasks = self.__tasks[:-1] - assert task_to_remove not in self.__tasks + self.__tasks.remove(task_to_remove) self.item_updated(redraw=True, ui=False) # cuz node displays task number - we should redraw def _sorted_tasks(self, order: TaskSortOrder) -> List["Task"]: @@ -611,252 +251,17 @@ def tasks_iter(self, *, order: Optional[TaskSortOrder] = None): return (x for x in self.__tasks) return self._sorted_tasks(order) - def get_task_pos(self, task: "Task", pos_id: int) -> Tuple[QPointF, int]: - #assert task in self.__tasks - rect = self._get_bodyshape().boundingRect() - x, y = rect.topLeft().toTuple() - w, h = rect.size().toTuple() - d = task.draw_size() # TODO: this assumes size is same, so dont make it an instance method - r = d * 0.5 - - #w *= 0.5 - x += r - y += r - h -= d - w -= d - x += (d * pos_id % w) - y_shift = d * int(d * pos_id / w) - y += (y_shift % h) - return QPointF(x, y), int(y_shift / h) - - def _find_insert_index_for_task(self, task, prefer_back=False): - if task.state() == TaskState.IN_PROGRESS and not prefer_back: - return 0 - - if task.state() != TaskState.IN_PROGRESS and prefer_back: - return len(self.__tasks) - - # now fun thing: we either have IN_PROGRESS and prefer_back, or NOT IN_PROGRESS and NOT prefer_back - # and both cases have the same logic for position finding - for i, task in enumerate(self.__tasks): - if task.state() != TaskState.IN_PROGRESS: - return i - else: - return len(self.__tasks) + def tasks(self) -> FrozenSet["Task"]: + return frozenset(self.__tasks) def task_state_changed(self, task): """ here node might decide to highlight the task that changed state one way or another """ - if task.state() not in (TaskState.IN_PROGRESS, TaskState.GENERATING, TaskState.POST_GENERATING): - return + pass - # find a place - append_at = self._find_insert_index_for_task(task) - - if append_at == len(self.__tasks): # this is impossible case (in current impl of _find_insert_index_for_task) (cuz task is in __tasks, and it's not in IN_PROGRESS) - return - - idx = self.__tasks.index(task) - if idx <= append_at: # already in place (and ignore moving further - return - - # place where it has to be - for i in reversed(range(append_at + 1, idx+1)): - self.__tasks[i] = self.__tasks[i-1] - self.__make_task_child_with_position(self.__tasks[i], *self.get_task_pos(self.__tasks[i], i), animate=True) - self.__tasks[append_at] = task - self.__make_task_child_with_position(self.__tasks[append_at], *self.get_task_pos(task, append_at), animate=True) - - # - # interface - - # helper - def __draw_single_item(self, item, size=(1.0, 1.0), drawing_widget=None): - if isinstance(item, Parameter): - if not item.visible(): - return - param_name = item.name() - param_label = item.label() or '' - parent_layout = item.parent() - idstr = f'_{self.get_id()}' - assert isinstance(parent_layout, ParametersLayoutBase) - imgui.push_item_width(imgui.get_window_width() * parent_layout.relative_size_for_child(item)[0] * 2 / 3) - - changed = False - expr_changed = False - - new_item_val = None - new_item_expression = None - - try: - if item.has_expression(): - with imgui.colored(imgui.COLOR_FRAME_BACKGROUND, 0.1, 0.4, 0.1): - expr_changed, newval = imgui.input_text('##'.join((param_label, param_name, idstr)), item.expression(), 256, flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) - if expr_changed: - new_item_expression = newval - elif item.has_menu(): - menu_order, menu_items = item.get_menu_items() - - if param_name not in self.__nodeui_menucache: - self.__nodeui_menucache[param_name] = {'menu_items_inv': {v: k for k, v in menu_items.items()}, - 'menu_order_inv': {v: i for i, v in enumerate(menu_order)}} - - menu_items_inv = self.__nodeui_menucache[param_name]['menu_items_inv'] - menu_order_inv = self.__nodeui_menucache[param_name]['menu_order_inv'] - if item.is_readonly() or item.is_locked(): # TODO: treat locked items somehow different, but for now it's fine - imgui.text(menu_items_inv[item.value()]) - return - else: - changed, val = imgui.combo('##'.join((param_label, param_name, idstr)), menu_order_inv[menu_items_inv[item.value()]], menu_order) - if changed: - new_item_val = menu_items[menu_order[val]] - else: - if item.is_readonly() or item.is_locked(): # TODO: treat locked items somehow different, but for now it's fine - imgui.text(f'{item.value()}') - if item.label(): - imgui.same_line() - imgui.text(f'{item.label()}') - return - param_type = item.type() - if param_type == NodeParameterType.BOOL: - changed, newval = imgui.checkbox('##'.join((param_label, param_name, idstr)), item.value()) - elif param_type == NodeParameterType.INT: - #changed, newval = imgui.slider_int('##'.join((param_label, param_name, idstr)), item.value(), 0, 10) - slider_limits = item.display_value_limits() - if slider_limits[0] is not None: - changed, newval = imgui.slider_int('##'.join((param_label, param_name, idstr)), item.value(), *slider_limits) - else: - changed, newval = imgui.input_int('##'.join((param_label, param_name, idstr)), item.value(), flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) - if imgui.begin_popup_context_item(f'item context menu##{param_name}', 2): - imgui.selectable('toggle expression') - imgui.end_popup() - elif param_type == NodeParameterType.FLOAT: - #changed, newval = imgui.slider_float('##'.join((param_label, param_name, idstr)), item.value(), 0, 10) - slider_limits = item.display_value_limits() - if slider_limits[0] is not None and slider_limits[1] is not None: - changed, newval = imgui.slider_float('##'.join((param_label, param_name, idstr)), item.value(), *slider_limits) - else: - changed, newval = imgui.input_float('##'.join((param_label, param_name, idstr)), item.value(), flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) - elif param_type == NodeParameterType.STRING: - if item.is_text_multiline(): - # TODO: this below is a temporary solution. it only gives 8192 extra symbols for editing, but currently there is no proper way around with current pyimgui version - imgui.begin_group() - ed_butt_pressed = imgui.small_button(f'open in external window##{param_name}') - changed, newval = imgui.input_text_multiline('##'.join((param_label, param_name, idstr)), item.unexpanded_value(), len(item.unexpanded_value()) + 1024*8, flags=imgui.INPUT_TEXT_ALLOW_TAB_INPUT | imgui.INPUT_TEXT_ENTER_RETURNS_TRUE | imgui.INPUT_TEXT_CTRL_ENTER_FOR_NEW_LINE) - imgui.end_group() - if ed_butt_pressed: - hl = StringParameterEditor.SyntaxHighlight.NO_HIGHLIGHT - if item.syntax_hint() == 'python': - hl = StringParameterEditor.SyntaxHighlight.PYTHON - wgt = StringParameterEditor(syntax_highlight=hl, parent=drawing_widget) - wgt.setAttribute(Qt.WA_DeleteOnClose, True) - wgt.set_text(item.unexpanded_value()) - wgt.edit_done.connect(lambda x, sc=self.scene(), id=self.get_id(), it=item: sc.change_node_parameter(id, item, x)) - wgt.set_title(f'editing parameter "{param_name}"') - wgt.show() - else: - changed, newval = imgui.input_text('##'.join((param_label, param_name, idstr)), item.unexpanded_value(), 256, flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) - else: - raise NotImplementedError() - if changed: - new_item_val = newval - - # item context menu popup - popupid = '##'.join((param_label, param_name, idstr)) # just to make sure no names will collide with full param imgui lables - if imgui.begin_popup_context_item(f'Item Context Menu##{popupid}', 2): - if item.can_have_expressions() and not item.has_expression(): - if imgui.selectable(f'enable expression##{popupid}')[0]: - expr_changed = True - # try to turn backtick expressions into normal one - if item.type() == NodeParameterType.STRING: - new_item_expression = item.python_from_expandable_string(item.unexpanded_value()) - else: - new_item_expression = str(item.value()) - if item.has_expression(): - if imgui.selectable(f'delete expression##{popupid}')[0]: - try: - value = item.value() - except ParameterExpressionError as e: - value = item.default_value() - expr_changed = True - changed = True - new_item_val = value - new_item_expression = None - imgui.end_popup() - finally: - imgui.pop_item_width() - - if changed or expr_changed: - scene: QGraphicsImguiScene = self.scene() - # TODO: op below may fail, so callback to display error should be provided - scene.change_node_parameter(self.get_id(), item, - new_item_val if changed else ..., - new_item_expression if expr_changed else ...) - - elif isinstance(item, Separator): - imgui.separator() - elif isinstance(item, OneLineParametersLayout): - first_time = True - for child in item.items(recursive=False): - h, w = item.relative_size_for_child(child) - if isinstance(child, Parameter): - if not child.visible(): - continue - if first_time: - first_time = False - else: - imgui.same_line() - self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) - elif isinstance(item, CollapsableVerticalGroup): - expanded, _ = imgui.collapsing_header(f'{item.label()}##{item.name()}') - if expanded: - imgui.indent(5) - for child in item.items(recursive=False): - h, w = item.relative_size_for_child(child) - self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) - imgui.unindent(5) - imgui.separator() - elif isinstance(item, ParametersLayoutBase): - imgui.indent(5) - for child in item.items(recursive=False): - h, w = item.relative_size_for_child(child) - if isinstance(child, Parameter): - if not child.visible(): - continue - self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) - imgui.unindent(5) - elif isinstance(item, ParametersLayoutBase): - for child in item.items(recursive=False): - h, w = item.relative_size_for_child(child) - if isinstance(child, Parameter): - if not child.visible(): - continue - self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) - else: - raise NotImplementedError(f'unknown parameter hierarchy item to display {type(item)}') - - # main dude def draw_imgui_elements(self, drawing_widget): - imgui.text(f'Node {self.get_id()}, type "{self.__node_type}", name {self.__name}') - - if imgui.selectable(f'parameters##{self.__name}', self.__ui_selected_tab == 0, width=imgui.get_window_width() * 0.5 * 0.7)[1]: - self.__ui_selected_tab = 0 - imgui.same_line() - if imgui.selectable(f'description##{self.__name}', self.__ui_selected_tab == 1, width=imgui.get_window_width() * 0.5 * 0.7)[1]: - self.__ui_selected_tab = 1 - imgui.separator() - - if self.__ui_selected_tab == 0: - if self.__nodeui is not None: - self.__draw_single_item(self.__nodeui.main_parameter_layout(), drawing_widget=drawing_widget) - elif self.__ui_selected_tab == 1: - if self.__node_type in self.scene().node_types() and imgui.button('open manual page'): - plugin_info = self.scene().node_types()[self.__node_type].plugin_info - category = plugin_info.category - package = plugin_info.package_name - QDesktopServices.openUrl(self.__manual_url_base + f'/nodes/{category}{f"/{package}" if package else ""}/{self.__node_type}.html') - imgui.text(self.scene().node_types()[self.__node_type].description if self.__node_type in self.scene().node_types() else 'error') + pass # base item doesn't draw anything def add_connection(self, new_connection: "NodeConnection"): self.__connections.add(new_connection) @@ -878,10 +283,7 @@ def remove_connection(self, connection: "NodeConnection"): self.__connections.remove(connection) def itemChange(self, change, value): - if change == QGraphicsItem.ItemSelectedHasChanged: - if value and self.scene().get_inspected_item() == self: # item was just selected, And is the first selected - self.scene()._node_selected(self) - elif change == QGraphicsItem.ItemSceneChange: # just before scene change + if change == QGraphicsItem.ItemSceneChange: # just before scene change conns = self.__connections.copy() if len(self.__tasks): logger.warning(f'node {self.get_id()}({self.node_name()}) has tasks at the moment of deletion, orphaning the tasks') @@ -892,278 +294,22 @@ def itemChange(self, change, value): assert connection.scene() is not None connection.scene().removeItem(connection) assert len(self.__connections) == 0 - elif change == QGraphicsItem.ItemPositionChange: - if self.__move_start_position is None: - self.__move_start_position = self.pos() - for connection in self.__connections: - connection.prepareGeometryChange() return super(Node, self).itemChange(change, value) - def mousePressEvent(self, event: QGraphicsSceneMouseEvent): - if event.button() == Qt.LeftButton and self.__ui_interactor is None: - pos = event.scenePos() - r2 = (self.__input_radius + 0.5*self.__line_width)**2 - node_viewer = event.widget().parent() - assert isinstance(node_viewer, nodeeditor.NodeEditor) - - # check expand button - expand_button_shape = self._get_expandbutton_shape() - if expand_button_shape.contains(event.pos()): - self.set_expanded(not self.__expanded) - event.ignore() - return - - if self.__inputs: # may be None if first nodes update hasn't arrived before mouse event - for input in self.__inputs: - inpos = self.get_input_position(input) - if QPointF.dotProduct(inpos - pos, inpos - pos) <= r2 and node_viewer.request_ui_focus(self): - snap_points = [y for x in self.scene().nodes() if x != self for y in x.output_snap_points()] - displayer = NodeConnectionCreatePreview(None, self, '', input, snap_points, 15, self._ui_interactor_finished) - self.scene().addItem(displayer) - self.__ui_interactor = displayer - self.__ui_grabbed_conn = input - self.__ui_widget = node_viewer - event.accept() - self.__ui_interactor.mousePressEvent(event) - return - - if self.__outputs: - for output in self.__outputs: - outpos = self.get_output_position(output) - if QPointF.dotProduct(outpos - pos, outpos - pos) <= r2 and node_viewer.request_ui_focus(self): - snap_points = [y for x in self.scene().nodes() if x != self for y in x.input_snap_points()] - displayer = NodeConnectionCreatePreview(self, None, output, '', snap_points, 15, self._ui_interactor_finished) - self.scene().addItem(displayer) - self.__ui_interactor = displayer - self.__ui_grabbed_conn = output - self.__ui_widget = node_viewer - event.accept() - self.__ui_interactor.mousePressEvent(event) - return - - if not self._get_nodeshape().contains(event.pos()): - event.ignore() - return - - super(Node, self).mousePressEvent(event) - self.__move_start_selection = {self} - self.__move_start_position = None - - # check for special picking: shift+move should move all upper connected nodes - if event.modifiers() & Qt.ShiftModifier or event.modifiers() & Qt.ControlModifier: - selecting_inputs = event.modifiers() & Qt.ShiftModifier - selecting_outputs = event.modifiers() & Qt.ControlModifier - extra_selected_nodes = set() - if selecting_inputs: - extra_selected_nodes.update(self.input_nodes()) - if selecting_outputs: - extra_selected_nodes.update(self.output_nodes()) - - extra_selected_nodes_ordered = list(extra_selected_nodes) - for relnode in extra_selected_nodes_ordered: - relnode.setSelected(True) - relrelnodes = set() - if selecting_inputs: - relrelnodes.update(node for node in relnode.input_nodes() if node not in extra_selected_nodes) - if selecting_outputs: - relrelnodes.update(node for node in relnode.output_nodes() if node not in extra_selected_nodes) - extra_selected_nodes_ordered.extend(relrelnodes) - extra_selected_nodes.update(relrelnodes) - self.setSelected(True) - for item in self.scene().selectedItems(): - if isinstance(item, Node): - self.__move_start_selection.add(item) - item.__move_start_position = None - - if event.button() == Qt.RightButton: - # context menu time - view = event.widget().parent() - assert isinstance(view, nodeeditor.NodeEditor) - view.show_node_menu(self) - event.accept() - - def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent): - # if self.__ui_interactor is not None: - # event.accept() - # self.__ui_interactor.mouseMoveEvent(event) - # return - super(Node, self).mouseMoveEvent(event) - - def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): - # if self.__ui_interactor is not None: - # event.accept() - # self.__ui_interactor.mouseReleaseEvent(event) - # return - super(Node, self).mouseReleaseEvent(event) - if self.__move_start_position is not None: - if self.scene().node_snapping_enabled(): - for node in self.__move_start_selection: - pos = node.pos() - snapx = node.base_width / 4 - snapy = node.base_height / 4 - node.setPos(round(pos.x() / snapx) * snapx, - round(pos.y() / snapy) * snapy) - self.scene()._nodes_were_moved([(node, node.__move_start_position) for node in self.__move_start_selection]) - for node in self.__move_start_selection: - node.__move_start_position = None - - def hoverMoveEvent(self, event): - self.__hoverover_pos = event.pos() - - def hoverLeaveEvent(self, event): - self.__hoverover_pos = None - self.update() - - @Slot(object) - def _ui_interactor_finished(self, snap_point: Optional["NodeConnSnapPoint"]): - assert self.__ui_interactor is not None - call_later(lambda x: logger.debug(f'later removing {x}') or x.scene().removeItem(x), self.__ui_interactor) - if self.scene() is None: # if scheduler deleted us while interacting - return - # NodeConnection._dbg_shitlist.append(self.__ui_interactor) - grabbed_conn = self.__ui_grabbed_conn - self.__ui_widget.release_ui_focus(self) - self.__ui_widget = None - self.__ui_interactor = None - self.__ui_grabbed_conn = None - - # actual node reconection - if snap_point is None: - logger.debug('no change') - return - scene: QGraphicsImguiScene = self.scene() - setting_out = not snap_point.connection_is_input() - scene.add_connection(snap_point.node().get_id() if setting_out else self.get_id(), - snap_point.connection_name() if setting_out else grabbed_conn, - snap_point.node().get_id() if not setting_out else self.get_id(), - snap_point.connection_name() if not setting_out else grabbed_conn) - - -class NodeConnection(NetworkItem): - def __init__(self, id: int, nodeout: Node, nodein: Node, outname: str, inname: str): - super(NodeConnection, self).__init__(id) - self.setFlags(QGraphicsItem.ItemSendsGeometryChanges) # QGraphicsItem.ItemIsSelectable | - self.setAcceptHoverEvents(True) # for highlights + +class NodeConnection(SceneNetworkItem): + def __init__(self, scene: GraphicsSceneBase, id: int, nodeout: Node, nodein: Node, outname: str, inname: str): + super().__init__(scene, id) + self.__nodeout = nodeout self.__nodein = nodein self.__outname = outname self.__inname = inname - self.setZValue(-1) - self.__line_width = 6 # TODO: rename it to match what it represents - self.__wire_pick_radius = 15 - self.__pick_radius2 = 100**2 - self.__curv = 150 - self.__wire_highlight_radius = 5 - - self.__temporary_invalid = False - - self.__ui_interactor: Optional[NodeConnectionCreatePreview] = None - self.__ui_widget: Optional[nodeeditor.NodeEditor] = None - self.__ui_last_pos = QPointF() - self.__ui_grabbed_beginning: bool = True - - self.__pen = QPen(QColor(64, 64, 64, 192)) - self.__pen.setWidthF(3) - self.__pen_highlight = QPen(QColor(92, 92, 92, 192)) - self.__pen_highlight.setWidthF(3) - self.__thick_pen = QPen(QColor(144, 144, 144, 128)) - self.__thick_pen.setWidthF(4) - self.__last_drawn_path: Optional[QPainterPath] = None - - self.__stroker = QPainterPathStroker() - self.__stroker.setWidth(2*self.__wire_pick_radius) - - self.__hoverover_pos = None nodein.add_connection(self) nodeout.add_connection(self) - def distance_to_point(self, pos: QPointF): - """ - returns approx distance to a given point - currently it has the most crude implementation - :param pos: - :return: - """ - - line = self.get_painter_path() - # determine where to start - p0 = self.__nodeout.get_output_position(self.__outname) - p1 = self.__nodein.get_input_position(self.__inname) - - if length2(p0-pos) < length2(p1-pos): # pos closer to p0 - curper = 0 - curstep = 0.1 - lastsqlen = length2(p0 - pos) - else: - curper = 1 - curstep = -0.1 - lastsqlen = length2(p1 - pos) - - sqlen = lastsqlen - while 0 <= curper <= 1: - curper += curstep - sqlen = length2(line.pointAtPercent(curper) - pos) - if sqlen > lastsqlen: - curstep *= -0.1 - if abs(sqlen - lastsqlen) < 0.001**2 or abs(curstep) < 1e-7: - break - lastsqlen = sqlen - - return sqrt(sqlen) - - def boundingRect(self) -> QRectF: - if self.__outname not in self.__nodeout.output_names() or self.__inname not in self.__nodein.input_names(): - self.__temporary_invalid = True - return QRectF() - self.__temporary_invalid = False - hlw = self.__line_width - line = self.get_painter_path() - return line.boundingRect().adjusted(-hlw - self.__wire_pick_radius, -hlw, hlw + self.__wire_pick_radius, hlw) - # inputpos = self.__nodeout.get_output_position(self.__outname) - # outputpos = self.__nodein.get_input_position(self.__inname) - # return QRectF(QPointF(min(inputpos.x(), outputpos.x()) - hlw, min(inputpos.y(), outputpos.y()) - hlw), - # QPointF(max(inputpos.x(), outputpos.x()) + hlw, max(inputpos.y(), outputpos.y()) + hlw)) - - def shape(self): - # this one is mainly needed for proper selection and item picking - return self.__stroker.createStroke(self.get_painter_path()) - - def get_painter_path(self, close_path=False): - line = QPainterPath() - - p0 = self.__nodeout.get_output_position(self.__outname) - p1 = self.__nodein.get_input_position(self.__inname) - curv = self.__curv - curv = min((p0-p1).manhattanLength()*0.5, curv) - line.moveTo(p0) - line.cubicTo(p0 + QPointF(0, curv), p1 - QPointF(0, curv), p1) - if close_path: - line.cubicTo(p1 - QPointF(0, curv), p0 + QPointF(0, curv), p0) - return line - - def paint(self, painter: PySide2.QtGui.QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: - if self.__temporary_invalid: - return - if self.__ui_interactor is not None: # if interactor exists - it does all the drawing - return - line = self.get_painter_path() - - painter.setPen(self.__pen) - - if self.__hoverover_pos is not None: - hldiag = QPointF(self.__wire_highlight_radius, self.__wire_highlight_radius) - if line.intersects(QRectF(self.__hoverover_pos - hldiag, self.__hoverover_pos + hldiag)): - painter.setPen(self.__pen_highlight) - - if self.isSelected(): - painter.setPen(self.__thick_pen) - - painter.drawPath(line) - # painter.drawRect(self.boundingRect()) - self.__last_drawn_path = line - def output(self) -> Tuple[Node, str]: return self.__nodeout, self.__outname @@ -1194,128 +340,8 @@ def set_input(self, node: Node, input_name: str = 'main'): else: self.__inname = input_name - def hoverMoveEvent(self, event): - self.__hoverover_pos = event.pos() - - def hoverLeaveEvent(self, event): - self.__hoverover_pos = None - self.update() - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent): - event.ignore() - if event.button() != Qt.LeftButton: - return - line = self.get_painter_path(close_path=True) - circle = QPainterPath() - circle.addEllipse(event.scenePos(), self.__wire_pick_radius, self.__wire_pick_radius) - if self.__ui_interactor is None and line.intersects(circle): - logger.debug('wire candidate for picking detected') - wgt = event.widget() - if wgt is None: - return - - p = event.scenePos() - p0 = self.__nodeout.get_output_position(self.__outname) - p1 = self.__nodein.get_input_position(self.__inname) - d02 = QPointF.dotProduct(p0 - p, p0 - p) - d12 = QPointF.dotProduct(p1 - p, p1 - p) - if d02 > self.__pick_radius2 and d12 > self.__pick_radius2: # if picked too far from ends - just select - super(NodeConnection, self).mousePressEvent(event) - event.accept() - return - - # this way we report to scene event handler that we are candidates for picking - if hasattr(event, 'wire_candidates'): - event.wire_candidates.append((self.distance_to_point(p), self)) - - def post_mousePressEvent(self, event: QGraphicsSceneMouseEvent): - """ - this will be called by scene as continuation of mousePressEvent - IF scene decides so. - :param event: - :return: - """ - wgt = event.widget() - p = event.scenePos() - p0 = self.__nodeout.get_output_position(self.__outname) - p1 = self.__nodein.get_input_position(self.__inname) - d02 = QPointF.dotProduct(p0 - p, p0 - p) - d12 = QPointF.dotProduct(p1 - p, p1 - p) - node_viewer = wgt.parent() - assert isinstance(node_viewer, nodeeditor.NodeEditor) - if node_viewer.request_ui_focus(self): - event.accept() - - output_picked = d02 < d12 - if output_picked: - snap_points = [y for x in self.scene().nodes() if x != self.__nodein for y in x.output_snap_points() ] - else: - snap_points = [y for x in self.scene().nodes() if x != self.__nodeout for y in x.input_snap_points()] - self.__ui_interactor = NodeConnectionCreatePreview(None if output_picked else self.__nodeout, - self.__nodein if output_picked else None, - self.__outname, self.__inname, - snap_points, 15, self._ui_interactor_finished, True) - self.update() - self.__ui_widget = node_viewer - self.scene().addItem(self.__ui_interactor) - self.__ui_interactor.mousePressEvent(event) - - def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: - # if self.__ui_interactor is not None: # redirect input, cuz scene will direct all events to this item. would be better to change focus, but so far scene.setFocusItem did not work as expected - # self.__ui_interactor.mouseMoveEvent(event) - # event.accept() - super(NodeConnection, self).mouseMoveEvent(event) - - def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: - # event.ignore() - # if event.button() != Qt.LeftButton: - # return - # if self.__ui_interactor is not None: # redirect input, cuz scene will direct all events to this item. would be better to change focus, but so far scene.setFocusItem did not work as expected - # self.__ui_interactor.mouseReleaseEvent(event) - # event.accept() - # self.ungrabMouse() - logger.debug('ungrabbing mouse') - self.ungrabMouse() - super(NodeConnection, self).mouseReleaseEvent(event) - - # _dbg_shitlist = [] - @Slot(object) - def _ui_interactor_finished(self, snap_point: Optional["NodeConnSnapPoint"]): - assert self.__ui_interactor is not None - call_later(lambda x: logger.debug(f'later removing {x}') or x.scene().removeItem(x), self.__ui_interactor) - if self.scene() is None: # if scheduler deleted us while interacting - return - # NodeConnection._dbg_shitlist.append(self.__ui_interactor) - self.__ui_widget.release_ui_focus(self) - self.__ui_widget = None - is_cutting = self.__ui_interactor.is_cutting() - self.__ui_interactor = None - self.update() - - # are we cutting the wire - if is_cutting: - self.scene().cut_connection_by_id(self.get_id()) - return - - # actual node reconection - if snap_point is None: - logger.debug('no change') - return - scene: QGraphicsImguiScene = self.scene() - changing_out = not snap_point.connection_is_input() - scene.change_connection_by_id(self.get_id(), - to_outnode_id=snap_point.node().get_id() if changing_out else None, - to_outname=snap_point.connection_name() if changing_out else None, - to_innode_id=None if changing_out else snap_point.node().get_id(), - to_inname=None if changing_out else snap_point.connection_name()) - # scene.request_node_connection_change(self.get_id(), - # snap_point.node().get_id() if changing_out else None, - # snap_point.connection_name() if changing_out else None, - # None if changing_out else snap_point.node().get_id(), - # None if changing_out else snap_point.connection_name()) - def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value): - if change == QGraphicsItem.ItemSceneChange: + if change == QGraphicsItem.ItemSceneChange: # just before scene change if value == self.__nodein.scene(): self.__nodein.add_connection(self) else: @@ -1324,25 +350,12 @@ def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value): self.__nodeout.add_connection(self) else: self.__nodeout.remove_connection(self) - return super(NodeConnection, self).itemChange(change, value) - - -class Task(NetworkItemWithUI, WatchableNetworkItem): - __brushes = None - __borderpen = None - __paused_pen = None - - def __init__(self, task_data: TaskData): - super(Task, self).__init__(task_data.id) - self.setAcceptHoverEvents(True) - self.__hoverover_pos = None - #self.setFlags(QGraphicsItem.ItemIsSelectable) - self.setZValue(1) - # self.__name = name - # self.__state = TaskState.WAITING - # self.__paused = False - # self.__progress = None - self.__layer = 0 # draw layer from 0 - main up to inf. kinda like LOD with highres being 0 + return super().itemChange(change, value) + + +class Task(SceneNetworkItemWithUI, WatchableNetworkItem): + def __init__(self, scene: GraphicsSceneBase, task_data: TaskData): + super().__init__(scene, task_data.id) # self.__state_details_raw = None self.__state_details_cached = None @@ -1354,147 +367,38 @@ def __init__(self, task_data: TaskData): self.__inv_stat_total_time: Optional[Tuple[float, float]] = None self.__ui_attributes: dict = {} self.__ui_env_res_attributes: Optional[EnvironmentResolverArguments] = None - self.__requested_invocs_while_selected = set() - self.__size = 16 - self.__line_width = 1.5 self.__node: Optional[Node] = None - self.__ui_interactor = None - self.__press_pos = None - - self.__animation_group: Optional[QSequentialAnimationGroup] = None - self.__final_pos = None - self.__final_layer = None - - self.__visible_layers_count = 2 - - self.__mainshape_cache = None # NOTE: DYNAMIC SIZE OR LINE WIDTH ARE NOT SUPPORTED HERE! - self.__selshape_cache = None - self.__pausedshape_cache = None - self.__bound_cache = None - - if self.__borderpen is None: - Task.__borderpen = [QPen(QColor(96, 96, 96, 255), self.__line_width), - QPen(QColor(128, 128, 128, 255), self.__line_width), - QPen(QColor(192, 192, 192, 255), self.__line_width)] - if self.__brushes is None: - # brushes and paused_pen are precalculated for several layers with different alphas, just not to calc them in paint - def lerp(a, b, t): - return a*(1.0-t) + b*t - - def lerpclr(c1, c2, t): - color = c1 - color.setAlphaF(lerp(color.alphaF(), c2.alphaF(), t)) - color.setRedF(lerp(color.redF(), c2.redF(), t)) - color.setGreenF(lerp(color.greenF(), c2.redF(), t)) - color.setBlueF(lerp(color.blueF(), c2.redF(), t)) - return color - - Task.__brushes = {TaskState.WAITING: QBrush(QColor(64, 64, 64, 192)), - TaskState.GENERATING: QBrush(QColor(32, 128, 128, 192)), - TaskState.READY: QBrush(QColor(32, 64, 32, 192)), - TaskState.INVOKING: QBrush(QColor(108, 108, 12, 192)), - TaskState.IN_PROGRESS: QBrush(QColor(128, 128, 32, 192)), - TaskState.POST_WAITING: QBrush(QColor(96, 96, 96, 192)), - TaskState.POST_GENERATING: QBrush(QColor(128, 32, 128, 192)), - TaskState.DONE: QBrush(QColor(32, 192, 32, 192)), - TaskState.ERROR: QBrush(QColor(192, 32, 32, 192)), - TaskState.SPAWNED: QBrush(QColor(32, 32, 32, 192)), - TaskState.DEAD: QBrush(QColor(16, 19, 22, 192)), - TaskState.SPLITTED: QBrush(QColor(64, 32, 64, 192)), - TaskState.WAITING_BLOCKED: QBrush(QColor(40, 40, 50, 192)), - TaskState.POST_WAITING_BLOCKED: QBrush(QColor(40, 40, 60, 192))} - for k, v in Task.__brushes.items(): - ocolor = v.color() - Task.__brushes[k] = [] - for i in range(self.__visible_layers_count): - color = lerpclr(ocolor, QColor.fromRgbF(0, 0, 0, 1), i*1.0/self.__visible_layers_count) - Task.__brushes[k].append(QColor(color)) - if self.__paused_pen is None: - ocolor = QColor(64, 64, 128, 192) - Task.__paused_pen = [] - for i in range(self.__visible_layers_count): - color = lerpclr(ocolor, QColor.fromRgbF(0, 0, 0, 1), i*1.0/self.__visible_layers_count) - Task.__paused_pen.append(QPen(color, self.__line_width*3)) - - def boundingRect(self) -> QRectF: - if self.__bound_cache is None: - lw = self.__line_width - self.__bound_cache = QRectF(QPointF(-0.5 * (self.__size + lw), -0.5 * (self.__size + lw)), - QSizeF(self.__size + lw, self.__size + lw)) - return self.__bound_cache - - def _get_mainpath(self) -> QPainterPath: - if self.__mainshape_cache is None: - path = QPainterPath() - path.addEllipse(-0.5 * self.__size, -0.5 * self.__size, - self.__size, self.__size) - self.__mainshape_cache = path - return self.__mainshape_cache - - def _get_selectshapepath(self) -> QPainterPath: - if self.__selshape_cache is None: - path = QPainterPath() - lw = self.__line_width - path.addEllipse(-0.5 * (self.__size + lw), -0.5 * (self.__size + lw), - self.__size + lw, self.__size + lw) - self.__selshape_cache = path - return self.__selshape_cache - - def _get_pausedpath(self) -> QPainterPath: - if self.__pausedshape_cache is None: - path = QPainterPath() - lw = self.__line_width - path.addEllipse(-0.5 * self.__size + 1.5*lw, -0.5 * self.__size + 1.5*lw, - self.__size - 3*lw, self.__size - 3*lw) - self.__pausedshape_cache = path - return self.__pausedshape_cache - - def paint(self, painter: PySide2.QtGui.QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: - if self.__layer >= self.__visible_layers_count: - return - if self.__node is None: # probably temporary state due to asyncronous incoming events from scheduler - return # or we can draw them somehow else? - screen_rect = painter.worldTransform().mapRect(self.boundingRect()) - - path = self._get_mainpath() - brush = self.__brushes[self.state()][self.__layer] - painter.fillPath(path, brush) - if self.__raw_data.progress: - arcpath = QPainterPath() - arcpath.arcTo(QRectF(-0.5*self.__size, -0.5*self.__size, self.__size, self.__size), - 90, -3.6*self.__raw_data.progress) - arcpath.closeSubpath() - painter.fillPath(arcpath, self.__brushes[TaskState.DONE][self.__layer]) - if self.paused(): - painter.setPen(self.__paused_pen[self.__layer]) - painter.drawPath(self._get_pausedpath()) - - if screen_rect.width() > 7: - if self.isSelected(): - painter.setPen(self.__borderpen[2]) - elif self.__hoverover_pos is not None: - painter.setPen(self.__borderpen[1]) - else: - painter.setPen(self.__borderpen[0]) - painter.drawPath(path) - def set_selected(self, selected: bool): - scene: QGraphicsImguiScene = self.scene() + scene: QGraphicsScene = self.scene() scene.clearSelection() if selected: self.setFlag(QGraphicsItem.ItemIsSelectable, True) self.setSelected(selected) - def name(self): + def parent_task_id(self) -> Optional[int]: + return self.__raw_data.parent_id + + def children_tasks_count(self) -> int: + return self.__raw_data.children_count + + def active_children_tasks_count(self) -> int: + return self.__raw_data.active_children_count + + def split_level(self) -> int: + return self.__raw_data.split_level + + def latest_invocation_attempt(self) -> int: + return self.__raw_data.work_data_invocation_attempt + + def name(self) -> str: return self.__raw_data.name def set_name(self, name: str): if name == self.__raw_data.name: return self.__raw_data.name = name - self.refresh_ui() self.item_updated(redraw=False, ui=True) def state(self) -> TaskState: @@ -1515,7 +419,6 @@ def set_groups(self, groups: Set[str]): if self.__raw_data.groups == groups: return self.__raw_data.groups = groups - self.refresh_ui() self.item_updated(redraw=False, ui=True) def attributes(self): @@ -1527,17 +430,6 @@ def in_group(self, group_name): def node(self) -> Optional[Node]: return self.__node - def draw_size(self): - return self.__size - - def layer_visible(self): - return self.__layer < self.__visible_layers_count - - def set_layer(self, layer: int): - assert layer >= 0 - self.__layer = layer - self.setZValue(1.0/(1.0 + layer)) - def set_state_details(self, state_details: Optional[str] = None): if self.__raw_data.state_details == state_details: return @@ -1567,7 +459,7 @@ def set_task_data(self, raw_data: TaskData): self.__node.task_state_changed(self) self.item_updated(redraw=True, ui=True) - def apply_task_delta(self, task_delta: TaskDelta, animated=True): + def apply_task_delta(self, task_delta: TaskDelta, get_node: Callable[[int], Node]): if task_delta.paused is not DataNotSet: self.set_state(None, task_delta.paused) if task_delta.state is not DataNotSet: @@ -1575,9 +467,9 @@ def apply_task_delta(self, task_delta: TaskDelta, animated=True): if task_delta.name is not DataNotSet: self.set_name(task_delta.name) if task_delta.node_id is not DataNotSet: - node: Optional[Node] = self.scene().get_node(task_delta.node_id) + node: Optional[Node] = get_node(task_delta.node_id) if node is not None: - node.add_task(self, animated) + node.add_task(self) if task_delta.work_data_invocation_attempt is not DataNotSet: self.__raw_data.work_data_invocation_attempt = task_delta.work_data_invocation_attempt if task_delta.node_output_name is not DataNotSet: @@ -1614,12 +506,6 @@ def set_progress(self, progress: float): def get_progress(self) -> Optional[float]: return self.__raw_data.progress if self.__raw_data else None - def add_item_watcher(self, watcher: "NetworkItemWatcher"): - super().add_item_watcher(watcher) - # additionally refresh ui if we are not being watched - if len(self.item_watchers()) == 1: # it's a first watcher - self.refresh_ui() - def item_updated(self, *, redraw: bool = False, ui: bool = False): super().item_updated(redraw=redraw, ui=ui) for watcher in self.item_watchers(): @@ -1701,9 +587,13 @@ def invocations_total_time(self, only_last_per_node: bool = True) -> float: else: return self.__inv_stat_total_time[1] + def invocation_logs_mapping(self) -> MappingProxyType[int, Dict[int, Union[IncompleteInvocationLogData, InvocationLogData]]]: + return MappingProxyType(self.__log) + def invocation_logs(self) -> List[Tuple[int, int, Union[IncompleteInvocationLogData, InvocationLogData]]]: """ - TODO: ensure immutable! + return tuples of (invocation_id, node_id, Log Data) + Entries will be grouped by node_id """ if self.__inv_log is None: self.__inv_log = [] @@ -1724,446 +614,16 @@ def set_environment_attributes(self, env_attrs: Optional[EnvironmentResolverArgu def environment_attributes(self) -> Optional[EnvironmentResolverArguments]: return self.__ui_env_res_attributes - def set_task_position(self, node: Node, pos: QPointF, layer: int): - """ - set task position to given node and give pos/layer inside that node - also cancels any active move animation - """ - if self.__animation_group is not None: - self.__animation_group.stop() - self.__animation_group.deleteLater() - self.__animation_group = None - - self.setParentItem(node) - if pos is not None: - self.setPos(pos) - if layer is not None: - self.set_layer(layer) - - def append_task_move_animation(self, node: Node, pos: QPointF, layer: int): - """ - set task position to given node and give pos/layer inside that node, - but do it with animation - """ - # first try to optimize, if we move on the same node to invisible layer - don't animate - if node == self.__node and layer >= self.__visible_layers_count and self.__animation_group is None: - return self.set_task_position(node, pos, layer) - - # - dist = ((pos if node is None else node.mapToScene(pos)) - self.final_scene_position()) - ldist = sqrt(QPointF.dotProduct(dist, dist)) - self.set_layer(0) - animgroup = self.__animation_group - if animgroup is None: - animgroup = QSequentialAnimationGroup(self.scene()) - animgroup.finished.connect(self._clear_animation_group) - anim_speed = max(1.0, animgroup.animationCount() - 2) # -2 to start speedup only after a couple anims in queue - start_node, start_pos = self.final_location() - new_animation = TaskAnimation(self, start_node, start_pos, node, pos, duration=max(1, int(ldist / anim_speed)), parent=animgroup) - if self.__animation_group is None: - self.setParentItem(None) - self.__animation_group = animgroup - - self.__final_pos = pos - self.__final_layer = layer - # turns out i do NOT need to add animation to group IF animgroup was passed as parent to animation - it's added automatically - # self.__animation_group.addAnimation(new_animation) - if self.__animation_group.state() != QAbstractAnimation.Running: - self.__animation_group.start() - def _set_parent_node(self, node: Optional[Node]): """ only to be called by Node class """ self.__node = node - def final_location(self) -> (Node, QPointF): - if self.__animation_group is not None: - assert self.__final_pos is not None - return self.__node, self.__final_pos - else: - return self.__node, self.pos() - - def final_scene_position(self) -> QPointF: - fnode, fpos = self.final_location() - if fnode is not None: - fpos = fnode.mapToScene(fpos) - return fpos - - def is_in_animation(self): - return self.__animation_group is not None - - @Slot() - def _clear_animation_group(self): - if self.__animation_group is not None: - ag, self.__animation_group = self.__animation_group, None - ag.stop() # just in case some recursion occures - ag.deleteLater() - self.setParentItem(self.__node) - self.setPos(self.__final_pos) - self.set_layer(self.__final_layer) - self.__final_pos = None - self.__final_layer = None - def setParentItem(self, item): """ use node.add_task if you want to set node for this task :param item: :return: """ - super(Task, self).setParentItem(item) - - def refresh_ui(self): - """ - unlike update - this method actually queries new task ui status - if task is not selected or not watched- does nothing - :return: - """ - if not self.isSelected() and len(self.item_watchers()) == 0: - return - self.scene().request_log_meta(self.get_id()) # update all task metadata: which nodes it ran on and invocation numbers only - self.scene().request_attributes(self.get_id()) - - for nid, invocs in self.__log.items(): - for invoc_id, invoc_dict in invocs.items(): - if invoc_dict is None: - continue - if (isinstance(invoc_dict, IncompleteInvocationLogData) - or invoc_dict.invocation_state != InvocationState.FINISHED) and invoc_id in self.__requested_invocs_while_selected: - self.__requested_invocs_while_selected.remove(invoc_id) - - def itemChange(self, change, value): - if change == QGraphicsItem.ItemSelectedHasChanged: - if value and self.__node is not None: # item was just selected - self.refresh_ui() - self.scene()._task_selected(self) - elif not value: - self.setFlag(QGraphicsItem.ItemIsSelectable, False) # we are not selectable any more by band selection until directly clicked - pass - - elif change == QGraphicsItem.ItemSceneChange: - if value is None: # removing item from scene - if self.__animation_group is not None: - self.__animation_group.stop() - self.__animation_group.clear() - self.__animation_group.deleteLater() - self.__animation_group = None - if self.__node is not None: - self.__node.remove_task(self) - return super(Task, self).itemChange(change, value) # TODO: maybe move this to scene's remove item? - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: - if not self._get_selectshapepath().contains(event.pos()): - event.ignore() - return - self.setFlag(QGraphicsItem.ItemIsSelectable, True) # if we are clicked - we are now selectable until unselected. This is to avoid band selection - super(Task, self).mousePressEvent(event) - self.__press_pos = event.scenePos() - - if event.button() == Qt.RightButton: - # context menu time - view = event.widget().parent() - assert isinstance(view, nodeeditor.NodeEditor) - view.show_task_menu(self) - event.accept() - - def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: - if self.__ui_interactor is None: - movedist = event.scenePos() - self.__press_pos - if QPointF.dotProduct(movedist, movedist) > 2500: # TODO: config this rad squared - self.__ui_interactor = TaskPreview(self) - self.scene().addItem(self.__ui_interactor) - if self.__ui_interactor: - self.__ui_interactor.mouseMoveEvent(event) - else: - super(Task, self).mouseMoveEvent(event) - - def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: - if self.__ui_interactor: - self.__ui_interactor.mouseReleaseEvent(event) - nodes = [x for x in self.scene().items(event.scenePos(), Qt.IntersectsItemBoundingRect) if isinstance(x, Node)] - if len(nodes) > 0: - logger.debug(f'moving item {self} to node {nodes[0]}') - self.scene().request_set_task_node(self.get_id(), nodes[0].get_id()) - call_later(self.__ui_interactor.scene().removeItem, self.__ui_interactor) - self.__ui_interactor = None - - else: - super(Task, self).mouseReleaseEvent(event) - - def hoverMoveEvent(self, event): - self.__hoverover_pos = event.pos() - - def hoverLeaveEvent(self, event): - self.__hoverover_pos = None - self.update() - - @staticmethod - def _draw_dict_table(attributes: dict, table_name: str): - imgui.columns(2, table_name) - imgui.separator() - imgui.text('name') - imgui.next_column() - imgui.text('value') - imgui.next_column() - imgui.separator() - for key, val in attributes.items(): - imgui.text(key) - imgui.next_column() - imgui.text(repr(val)) - imgui.next_column() - imgui.columns(1) - - # - # interface - def draw_imgui_elements(self, drawing_widget): - imgui.text(f'Task {self.get_id()} {self.__raw_data.name}') - imgui.text(f'state: {self.__raw_data.state.name}') - imgui.text(f'groups: {", ".join(self.__raw_data.groups)}') - imgui.text(f'parent id: {self.__raw_data.parent_id}') - imgui.text(f'children count: {self.__raw_data.children_count}') - imgui.text(f'split level: {self.__raw_data.split_level}') - imgui.text(f'invocation attempts: {self.__raw_data.work_data_invocation_attempt}') - - # first draw attributes - if self.__ui_attributes: - self._draw_dict_table(self.__ui_attributes, 'node_task_attributes') - - if self.__ui_env_res_attributes: - tab_expanded, _ = imgui.collapsing_header(f'environment resolver attributes##collapsing_node_task_environment_resolver_attributes') - if tab_expanded: - imgui.text(f'environment resolver: "{self.__ui_env_res_attributes.name()}"') - if self.__ui_env_res_attributes.arguments(): - self._draw_dict_table(self.__ui_env_res_attributes.arguments(), 'node_task_environment_resolver_attributes') - - # now draw log - imgui.text('Logs:') - for node_id, invocs in self.__log.items(): - node: Node = self.scene().get_node(node_id) - if node is None: - logger.warning(f'node for task {self.get_id()} does not exist') - continue - node_name: str = node.node_name() - node_expanded, _ = imgui.collapsing_header(f'node {node_id}' + (f' "{node_name}"' if node_name else '')) - if not node_expanded: # or invocs is None: - continue - for invoc_id, invoc_log in invocs.items(): - # TODO: pyimgui is not covering a bunch of fancy functions... watch when it's done - imgui.indent(10) - invoc_expanded, _ = imgui.collapsing_header(f'invocation {invoc_id}' + - (f', worker {invoc_log.worker_id}' if isinstance(invoc_log, InvocationLogData) is not None else '') + - f', time: {timedelta(seconds=round(invoc_log.invocation_runtime)) if invoc_log.invocation_runtime is not None else "N/A"}' + - f'###logentry_{invoc_id}') - if not invoc_expanded: - imgui.unindent(10) - continue - if invoc_id not in self.__requested_invocs_while_selected: - self.__requested_invocs_while_selected.add(invoc_id) - self.scene().request_log(invoc_id) - if isinstance(invoc_log, IncompleteInvocationLogData): - imgui.text('...fetching...') - else: - if invoc_log.stdout: - if imgui.button(f'open in viewer##{invoc_id}'): - fetch_and_open_log_viewer(self.scene(), invoc_id, drawing_widget, update_interval=None if invoc_log.invocation_state == InvocationState.FINISHED else 5) - - imgui.text_unformatted(invoc_log.stdout or '...nothing here...') - if invoc_log.invocation_state == InvocationState.IN_PROGRESS: - if imgui.button('update'): - logger.debug('clicked') - if invoc_id in self.__requested_invocs_while_selected: - self.__requested_invocs_while_selected.remove(invoc_id) - imgui.unindent(10) - - -class SnapPoint: - def pos(self) -> QPointF: - raise NotImplementedError() - - -class NodeConnSnapPoint(SnapPoint): - def __init__(self, node: Node, connection_name: str, connection_is_input: bool): - super(NodeConnSnapPoint, self).__init__() - self.__node = node - self.__conn_name = connection_name - self.__isinput = connection_is_input - - def node(self) -> Node: - return self.__node - - def connection_name(self) -> str: - return self.__conn_name - - def connection_is_input(self) -> bool: - return self.__isinput - - def pos(self) -> QPointF: - if self.__isinput: - return self.__node.get_input_position(self.__conn_name) - return self.__node.get_output_position(self.__conn_name) - - -class NodeConnectionCreatePreview(QGraphicsItem): - def __init__(self, nodeout: Optional[Node], nodein: Optional[Node], outname: str, inname: str, snap_points: List[NodeConnSnapPoint], snap_radius: float, report_done_here: Callable, do_cutting: bool = False): - super(NodeConnectionCreatePreview, self).__init__() - assert nodeout is None and nodein is not None or \ - nodeout is not None and nodein is None - self.setFlags(QGraphicsItem.ItemSendsGeometryChanges) - self.setZValue(10) - self.__nodeout = nodeout - self.__nodein = nodein - self.__outname = outname - self.__inname = inname - self.__snappoints = snap_points - self.__snap_radius2 = snap_radius * snap_radius - self.setZValue(-1) - self.__line_width = 4 - self.__curv = 150 - self.__breakdist2 = 200**2 - - self.__ui_last_pos = QPointF() - self.__finished_callback = report_done_here - - self.__pen = QPen(QColor(64, 64, 64, 192)) - self.__pen.setWidthF(3) - - self.__do_cutting = do_cutting - self.__cutpen = QPen(QColor(96, 32, 32, 192)) - self.__cutpen.setWidthF(3) - self.__cutpen.setStyle(Qt.DotLine) - - self.__is_snapping = False - - self.__orig_pos: Optional[QPointF] = None - - def get_painter_path(self): - if self.__nodein is not None: - p0 = self.__ui_last_pos - p1 = self.__nodein.get_input_position(self.__inname) - else: - p0 = self.__nodeout.get_output_position(self.__outname) - p1 = self.__ui_last_pos - - curv = self.__curv - curv = min((p0 - p1).manhattanLength() * 0.5, curv) - - line = QPainterPath() - line.moveTo(p0) - line.cubicTo(p0 + QPointF(0, curv), p1 - QPointF(0, curv), p1) - return line - - def boundingRect(self) -> QRectF: - hlw = self.__line_width - - if self.__nodein is not None: - inputpos = self.__ui_last_pos - outputpos = self.__nodein.get_input_position(self.__inname) - else: - inputpos = self.__nodeout.get_output_position(self.__outname) - outputpos = self.__ui_last_pos - - return QRectF(QPointF(min(inputpos.x(), outputpos.x()) - hlw, min(inputpos.y(), outputpos.y()) - hlw), - QPointF(max(inputpos.x(), outputpos.x()) + hlw, max(inputpos.y(), outputpos.y()) + hlw)) - - def paint(self, painter: PySide2.QtGui.QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: - line = self.get_painter_path() - if self.is_cutting(): - painter.setPen(self.__cutpen) - else: - painter.setPen(self.__pen) - painter.drawPath(line) - # painter.drawRect(self.boundingRect()) - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent): - if event.button() != Qt.LeftButton: - event.ignore() - return - self.grabMouse() - pos = event.scenePos() - closest_snap = self.get_closest_snappoint(pos) - self.__is_snapping = False - if closest_snap is not None: - pos = closest_snap.pos() - self.__is_snapping = True - self.prepareGeometryChange() - self.__ui_last_pos = pos - if self.__orig_pos is None: - self.__orig_pos = pos - event.accept() - - def mouseMoveEvent(self, event): - pos = event.scenePos() - closest_snap = self.get_closest_snappoint(pos) - self.__is_snapping = False - if closest_snap is not None: - pos = closest_snap.pos() - self.__is_snapping = True - self.prepareGeometryChange() - self.__ui_last_pos = pos - if self.__orig_pos is None: - self.__orig_pos = pos - event.accept() - - def is_cutting(self): - """ - wether or not interactor is it cutting the wire state - :return: - """ - return self.__do_cutting and not self.__is_snapping and self.__orig_pos is not None and length2(self.__orig_pos - self.__ui_last_pos) > self.__breakdist2 - - def get_closest_snappoint(self, pos: QPointF) -> Optional[NodeConnSnapPoint]: - - snappoints = [x for x in self.__snappoints if length2(x.pos() - pos) < self.__snap_radius2] - - if len(snappoints) == 0: - return None - - return min(snappoints, key=lambda x: length2(x.pos() - pos)) - - def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): - if event.button() != Qt.LeftButton: - event.ignore() - return - if self.__finished_callback is not None: - self.__finished_callback(self.get_closest_snappoint(event.scenePos())) - event.accept() - self.ungrabMouse() - - -class TaskPreview(QGraphicsItem): - def __init__(self, task: Task): - super(TaskPreview, self).__init__() - self.setZValue(10) - self.__size = 16 - self.__line_width = 1.5 - self.__finished_callback = None - self.setZValue(10) - - self.__borderpen = QPen(QColor(192, 192, 192, 255), self.__line_width) - self.__brush = QBrush(QColor(64, 64, 64, 128)) - - def boundingRect(self) -> QRectF: - lw = self.__line_width - return QRectF(QPointF(-0.5 * (self.__size + lw), -0.5 * (self.__size + lw)), - QSizeF(self.__size + lw, self.__size + lw)) - - def _get_mainpath(self) -> QPainterPath: - path = QPainterPath() - path.addEllipse(-0.5 * self.__size, -0.5 * self.__size, - self.__size, self.__size) - return path - - def paint(self, painter: PySide2.QtGui.QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: - path = self._get_mainpath() - brush = self.__brush - painter.fillPath(path, brush) - painter.setPen(self.__borderpen) - painter.drawPath(path) - - def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: - self.setPos(event.scenePos()) - - def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): - if self.__finished_callback is not None: - self.__finished_callback(event.scenePos()) # not used for now not to overcomplicate - event.accept() + super().setParentItem(item) diff --git a/src/lifeblood_viewer/graphics_items/node_extra_items.py b/src/lifeblood_viewer/graphics_items/node_extra_items.py index 8655b9a2..3f756436 100644 --- a/src/lifeblood_viewer/graphics_items/node_extra_items.py +++ b/src/lifeblood_viewer/graphics_items/node_extra_items.py @@ -37,6 +37,7 @@ def boundingRect(self) -> QRectF: def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: for name in self.__parent_node.output_names(): if len(self.__parent_node.output_connections(name)) > 1: + # TODO: review this shift = QPointF(self.__parent_node.mapFromScene(self.__parent_node.get_output_position(name)).x(), 0) painter.fillPath(self._arc_path.translated(shift), self.__brush) painter.setPen(self._text_pen) diff --git a/src/lifeblood_viewer/graphics_items/utils.py b/src/lifeblood_viewer/graphics_items/utils.py new file mode 100644 index 00000000..10589f73 --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/utils.py @@ -0,0 +1,13 @@ +from PySide2.QtCore import QPointF, QTimer + + +def call_later(callable, *args, **kwargs): #TODO: this repeats here and in nodeeditor + if len(args) == 0 and len(kwargs) == 0: + QTimer.singleShot(0, callable) + else: + QTimer.singleShot(0, lambda: callable(*args, **kwargs)) + + +def length2(v: QPointF): + return QPointF.dotProduct(v, v) + diff --git a/src/lifeblood_viewer/graphics_scene.py b/src/lifeblood_viewer/graphics_scene.py index 9737fa42..03429be7 100644 --- a/src/lifeblood_viewer/graphics_scene.py +++ b/src/lifeblood_viewer/graphics_scene.py @@ -4,12 +4,15 @@ import grandalf.layouts from types import MappingProxyType -from .graphics_items import Task, Node, NodeConnection +from .graphics_scene_container import GraphicsSceneWithNodesAndTasks +from .graphics_items import Task, Node, NodeConnection # from .db_misc import sql_init_script_nodes from .long_op import LongOperation, LongOperationData, LongOperationProcessor from .connection_worker import SchedulerConnectionWorker from .undo_stack import UndoStack, UndoableOperation, OperationCompletionDetails from .ui_snippets import UiNodeSnippetData +from .scene_item_factory_base import SceneItemFactoryBase +from .scene_data_controller import SceneDataController from .scene_ops import ( CompoundAsyncSceneOperation, CreateNodeOp, CreateNodesOp, @@ -40,7 +43,7 @@ logger = logging.get_logger('viewer') -class QGraphicsImguiScene(QGraphicsScene, LongOperationProcessor): +class QGraphicsImguiScene(GraphicsSceneWithNodesAndTasks, LongOperationProcessor, SceneDataController): # these are private signals to invoke shit on worker in another thread. QMetaObject's invokemethod is broken in pyside2 _signal_log_has_been_requested = Signal(int, object) _signal_log_meta_has_been_requested = Signal(int, object) @@ -96,13 +99,11 @@ class QGraphicsImguiScene(QGraphicsScene, LongOperationProcessor): operation_progress_updated = Signal(int, str, float) # operation id, name, progress 0.0 - 1.0 operation_finished = Signal(int) # operation id - def __init__(self, db_path: str = None, worker: Optional["SchedulerConnectionWorker"] = None, parent=None): + def __init__(self, scene_item_factory: SceneItemFactoryBase, db_path: str = None, worker: Optional["SchedulerConnectionWorker"] = None, parent=None): super(QGraphicsImguiScene, self).__init__(parent=parent) # to debug fuching bsp # self.setItemIndexMethod(QGraphicsScene.NoIndex) self.__config = get_config('viewer') - self.__task_dict: Dict[int, Task] = {} - self.__node_dict: Dict[int, Node] = {} - self.__node_connections_dict: Dict[int, NodeConnection] = {} + self.__scene_item_factory: SceneItemFactoryBase = scene_item_factory self.__db_path = db_path self.__nodes_table_name = None self.__cached_nodetypes: Dict[str, NodeTypeMetadata] = {} @@ -115,15 +116,9 @@ def __init__(self, db_path: str = None, worker: Optional["SchedulerConnectionWor self.__undo_stack: UndoStack = UndoStack() self.reset_undo_stack() - self.__session_node_id_mapping = {} # for consistent redo-undo involving node creation/deletion, as node_id will change on repetition - self.__session_node_id_mapping_rev = {} - self.__next_session_node_id = -1 self.__node_snapshots = {} # for undo/redo self.__selection_happening = False - # settings: - self.__node_snapping_enabled = True - if worker is None: self.__ui_connection_thread = QThread(self) # SchedulerConnectionThread(self) self.__ui_connection_worker = SchedulerConnectionWorker() @@ -206,9 +201,6 @@ def __init__(self, db_path: str = None, worker: Optional["SchedulerConnectionWor self._signal_poke_task_groups_update.connect(self.__ui_connection_worker.poke_task_groups_update) self._signal_poke_workers_update.connect(self.__ui_connection_worker.poke_workers_update) - def reset_undo_stack(self): - self.__undo_stack = UndoStack(max_undos=self.__config.get_option_noasync('viewer.max_undo_history_size', 100)) - def request_log(self, invocation_id: int, operation_data: Optional["LongOperationData"] = None): self._signal_log_has_been_requested.emit(invocation_id, operation_data) @@ -358,60 +350,33 @@ def request_workers_update(self): self._signal_poke_workers_update.emit() # - # - # + # Higher-level request functions: - def _session_node_id_to_id(self, session_id: int) -> Optional[int]: - """ - the whole idea of session id is to have it consistent through undo-redos + def regenerate_all_ready_tasks_for_node(self, node_id: int): """ - node_id = self.__session_node_id_mapping.get(session_id) - if node_id is not None and self.get_node(node_id) is None: - self.__session_node_id_mapping.pop(session_id) - self.__session_node_id_mapping_rev.pop(node_id) - node_id = None - return node_id - - def _session_node_update_id(self, session_id: int, new_node_id: int): - prev_node_id = self.__session_node_id_mapping.get(session_id) - self.__session_node_id_mapping[session_id] = new_node_id - if prev_node_id is not None: - self.__session_node_id_mapping_rev.pop(prev_node_id) - self.__session_node_id_mapping_rev[new_node_id] = session_id - # TODO: self.__session_node_id_mapping should be cleared when undo stack is truncated, but so far it's a little "memory leak" - - def _session_node_update_session_id(self, new_session_id: int, node_id: int): - if new_session_id in self.__session_node_id_mapping: - raise RuntimeError(f'given session id {new_session_id} is already assigned') - old_session_id = self.__session_node_id_mapping_rev.get(node_id) - self.__session_node_id_mapping_rev[node_id] = new_session_id - if old_session_id is not None: - self.__session_node_id_mapping.pop(old_session_id) - self.__session_node_id_mapping[new_session_id] = node_id - - def _session_node_id_from_id(self, node_id: int): - if node_id not in self.__session_node_id_mapping_rev: - while self._session_node_id_to_id(self.__next_session_node_id) is not None: # they may be taken by pasted nodes - self.__next_session_node_id -= 1 - self._session_node_update_id(self.__next_session_node_id, node_id) - self.__next_session_node_id -= 1 - return self.__session_node_id_mapping_rev[node_id] - - def _node_selected(self, node: Node): + all currently displayed tasks that are in states BEFORE invoking/in-progress, will be set to WAITING """ - node should inform scene when it's selected - I guess logically it should be the other way around, but so far - it seems that this is the way qt is doing this - """ - self.request_node_ui(node.get_id()) + self._change_all_task_states_for_node(node_id, (TaskState.READY, TaskState.WAITING_BLOCKED), TaskState.WAITING) - def _task_selected(self, task: Task): + def retry_all_error_tasks_for_node(self, node_id: int): """ - task should inform scene when it's selected - I guess logically it should be the other way around, but so far - it seems that this is the way qt is doing this + all currently displayed task that are in ERROR state will be reset to WAITING """ - pass + self._change_all_task_states_for_node(node_id, (TaskState.ERROR,), TaskState.WAITING) + + def _change_all_task_states_for_node(self, node_id: int, from_states: Tuple[TaskState, ...], to_state: TaskState): + node = self.get_node(node_id) + self.set_task_state( + [x.get_id() for x in node.tasks_iter() if x.state() in from_states], + to_state + ) + + # + # + # + + def reset_undo_stack(self): + self.__undo_stack = UndoStack(max_undos=self.__config.get_option_noasync('viewer.max_undo_history_size', 100)) # # @@ -425,14 +390,6 @@ def skip_dead(self) -> bool: def skip_archived_groups(self) -> bool: return self.__ui_connection_worker.skip_archived_groups() # should be fine and thread-safe in eyes of python - # settings - - def node_snapping_enabled(self): - return self.__node_snapping_enabled - - def set_node_snapping_enabled(self, enabled: bool): - self.__node_snapping_enabled = enabled - # def _nodes_were_moved(self, nodes_datas: Sequence[Tuple[Node, QPointF]]): @@ -657,6 +614,9 @@ def graph_full_update(self, graph_data: NodeGraphStructureData): to_del.append(item) continue existing_conn_ids[item.get_id()] = item + print('---') + print(existing_node_ids) + print('---') # delete things for item in to_del: @@ -675,7 +635,7 @@ def graph_full_update(self, graph_data: NodeGraphStructureData): if id in existing_node_ids: existing_node_ids[id].set_name(new_node_data.name) continue - new_node = Node(id, new_node_data.type, new_node_data.name or f'node #{id}') + new_node = self.__scene_item_factory.make_node(self, id, new_node_data.type, new_node_data.name or f'node #{id}') try: new_node.setPos(*self.node_position(id)) except ValueError: @@ -708,15 +668,23 @@ def graph_full_update(self, graph_data: NodeGraphStructureData): existing_conn_ids[id].set_output(existing_node_ids[new_conn_data.out_id], new_conn_data.out_name) existing_conn_ids[id].update() continue - new_conn = NodeConnection(id, existing_node_ids[new_conn_data.out_id], - existing_node_ids[new_conn_data.in_id], - new_conn_data.out_name, new_conn_data.in_name) + new_conn = self.__scene_item_factory.make_node_connection( + self, + id, + existing_node_ids[new_conn_data.out_id], + existing_node_ids[new_conn_data.in_id], + new_conn_data.out_name, new_conn_data.in_name + ) existing_conn_ids[id] = new_conn self.addItem(new_conn) if nodes_to_layout: self.layout_nodes(nodes_to_layout) + print('+++') + print(self.items()) + print(self.nodes()) + @timeit(0.05) @Slot(object, bool) def tasks_process_events(self, events: List[TaskEvent], first_time_getting_events: bool): @@ -737,7 +705,7 @@ def tasks_process_events(self, events: List[TaskEvent], first_time_getting_event elif isinstance(event, TasksUpdated): self.tasks_update(event.task_data) elif isinstance(event, TasksChanged): - self.tasks_deltas_apply(event.task_deltas, animated=not first_time_getting_events) + self.tasks_deltas_apply(event.task_deltas) #, animated=not first_time_getting_events) elif isinstance(event, TasksRemoved): existing_tasks = dict(self.tasks_dict()) for task_id in event.task_ids: @@ -745,7 +713,7 @@ def tasks_process_events(self, events: List[TaskEvent], first_time_getting_event self.removeItem(existing_tasks[task_id]) existing_tasks.pop(task_id) - def tasks_deltas_apply(self, task_deltas: List[TaskDelta], animated: bool = True): + def tasks_deltas_apply(self, task_deltas: List[TaskDelta]): for task_delta in task_deltas: task_id = task_delta.id task = self.get_task(task_id) @@ -757,7 +725,7 @@ def tasks_deltas_apply(self, task_deltas: List[TaskDelta], animated: bool = True if node is None: logger.warning('node not found during task delta processing, this will probably be fixed during next update') self.__tasks_to_try_reparent_during_node_update[task_id] = task_delta.node_id - task.apply_task_delta(task_delta, animated=animated) + task.apply_task_delta(task_delta, self.get_node) @timeit(0.05) @Slot(object) @@ -809,7 +777,7 @@ def tasks_update(self, tasks_data: TaskBatchData): for id, new_task_data in tasks_data.tasks.items(): if id not in existing_tasks: - new_task = Task(new_task_data) + new_task = self.__scene_item_factory.make_task(self, new_task_data) existing_tasks[id] = new_task if new_task_data.split_origin_task_id is not None and new_task_data.split_origin_task_id in existing_tasks: # TODO: bug: this and below will only work if parent/original tasks were created during previous updates origin_task = existing_tasks[new_task_data.split_origin_task_id] @@ -826,161 +794,165 @@ def tasks_update(self, tasks_data: TaskBatchData): self.__tasks_to_try_reparent_during_node_update[id] = new_task_data.node_id task.set_task_data(new_task_data) - @timeit(0.05) - @Slot(object) - def full_update(self, uidata: UiData): - raise DeprecationWarning('no use') - # logger.debug('full_update') - - if self.__db_uid is not None and self.__db_uid != uidata.db_uid: - logger.info('scheduler\'s database changed. resetting the view...') - self.save_node_layout() - self.clear() - self.__db_uid = None - self.__nodes_table_name = None - # this means we probably reconnected to another scheduler, so existing nodes need to be dropped - - if self.__db_uid is None: - self.__db_uid = uidata.db_uid - self.__nodes_table_name = f'nodes_{self.__db_uid}' - with sqlite3.connect(self.__db_path) as con: - con.executescript(sql_init_script_nodes.format(db_uid=self.__db_uid)) - - to_del = [] - to_del_tasks = {} - existing_node_ids: Dict[int, Node] = {} - existing_conn_ids: Dict[int, NodeConnection] = {} - existing_task_ids: Dict[int, Task] = {} - _perf_total = 0.0 - graph_data = uidata.graph_data - with performance_measurer() as pm: - for item in self.items(): - if isinstance(item, Node): # TODO: unify this repeating code and move the setting attribs to after all elements are created - if item.get_id() not in graph_data.nodes or item.node_type() != graph_data.nodes[item.get_id()].type: - to_del.append(item) - continue - existing_node_ids[item.get_id()] = item - # TODO: update all kind of attribs here, for now we just don't have any - elif isinstance(item, NodeConnection): - if item.get_id() not in graph_data.connections: - to_del.append(item) - continue - existing_conn_ids[item.get_id()] = item - # TODO: update all kind of attribs here, for now we just don't have any - elif isinstance(item, Task): - if item.get_id() not in uidata.tasks.tasks: - to_del.append(item) - if item.node() is not None: - if not item.node() in to_del_tasks: - to_del_tasks[item.node()] = [] - to_del_tasks[item.node()].append(item) - continue - existing_task_ids[item.get_id()] = item - _perf_item_classify = pm.elapsed() - _perf_total += pm.elapsed() - - # before we delete everything - we'll remove tasks from nodes to avoid deleting tasks one by one triggering tonns of animation - with performance_measurer() as pm: - for node, tasks in to_del_tasks.items(): - node.remove_tasks(tasks) - _perf_remove_tasks = pm.elapsed() - _perf_total += pm.elapsed() - with performance_measurer() as pm: - for item in to_del: - self.removeItem(item) - _perf_remove_items = pm.elapsed() - _perf_total += pm.elapsed() - # removing items might cascade things, like removing node will remove connections to that node - # so now we need to recheck existing items validity - # though not consistent scene states should not come in uidata at all - with performance_measurer() as pm: - for existings in (existing_node_ids, existing_task_ids, existing_conn_ids): - for item_id, item in tuple(existings.items()): - if item.scene() != self: - del existings[item_id] - _perf_revalidate = pm.elapsed() - _perf_total += pm.elapsed() - - nodes_to_layout = [] - with performance_measurer() as pm: - for id, new_node_data in graph_data.nodes.items(): - if id in existing_node_ids: - existing_node_ids[id].set_name(new_node_data.name) - continue - new_node = Node(id, new_node_data.type, new_node_data.name or f'node #{id}') - try: - new_node.setPos(*self.node_position(id)) - except ValueError: - nodes_to_layout.append(new_node) - existing_node_ids[id] = new_node - self.addItem(new_node) - _perf_create_nodes = pm.elapsed() - _perf_total += pm.elapsed() - - with performance_measurer() as pm: - for id, new_conn_data in graph_data.connections.items(): - if id in existing_conn_ids: - # ensure connections - innode, inname = existing_conn_ids[id].input() - outnode, outname = existing_conn_ids[id].output() - if innode.get_id() != new_conn_data.in_id or inname != new_conn_data.in_name: - existing_conn_ids[id].set_input(existing_node_ids[new_conn_data.in_id], new_conn_data.in_name) - existing_conn_ids[id].update() - if outnode.get_id() != new_conn_data.out_id or outname != new_conn_data.out_name: - existing_conn_ids[id].set_output(existing_node_ids[new_conn_data.out_id], new_conn_data.out_name) - existing_conn_ids[id].update() - continue - new_conn = NodeConnection(id, existing_node_ids[new_conn_data.out_id], - existing_node_ids[new_conn_data.in_id], - new_conn_data.out_name, new_conn_data.in_name) - existing_conn_ids[id] = new_conn - self.addItem(new_conn) - _perf_create_connections = pm.elapsed() - _perf_total += pm.elapsed() - - with performance_measurer() as pm: - for id, new_task_data in uidata.tasks.tasks.items(): - if id not in existing_task_ids: - new_task = Task(new_task_data) - existing_task_ids[id] = new_task - if new_task_data.split_origin_task_id is not None and new_task_data.split_origin_task_id in existing_task_ids: # TODO: bug: this and below will only work if parent/original tasks were created during previous updates - origin_task = existing_task_ids[new_task_data.split_origin_task_id] - new_task.setPos(origin_task.scenePos()) - elif new_task_data.parent_id is not None and new_task_data.parent_id in existing_task_ids: - origin_task = existing_task_ids[new_task_data.parent_id] - new_task.setPos(origin_task.scenePos()) - self.addItem(new_task) - task = existing_task_ids[id] - existing_node_ids[new_task_data.node_id].add_task(task) - task.set_task_data(new_task_data) - _perf_create_tasks = pm.elapsed() - _perf_total += pm.elapsed() - - # now layout nodes that need it - with performance_measurer() as pm: - if nodes_to_layout: - self.layout_nodes(nodes_to_layout) - _perf_layout = pm.elapsed() - _perf_total += pm.elapsed() - - with performance_measurer() as pm: - if self.__all_task_groups != uidata.task_groups: - self.__all_task_groups = uidata.task_groups - self.task_groups_updated.emit(uidata.task_groups) - _perf_task_groups_update = pm.elapsed() - _perf_total += pm.elapsed() - - if _perf_total > 0.04: # arbitrary threshold ~ 1/25 of a sec - logger.debug(f'update performed:\n' - f'{_perf_item_classify:.04f}:\tclassify\n' - f'{_perf_remove_tasks:.04f}:\tremove tasks\n' - f'{_perf_remove_items:.04f}:\tremove items\n' - f'{_perf_revalidate:.04f}:\trevalidate\n' - f'{_perf_create_nodes:.04f}:\tcreate nodes\n' - f'{_perf_create_connections:.04f}:\tcreate connections\n' - f'{_perf_create_tasks:.04f}:\tcreate tasks\n' - f'{_perf_layout:.04f}:\tlayout\n' - f'{_perf_task_groups_update:.04f}:\ttask group update') + # @timeit(0.05) + # @Slot(object) + # def full_update(self, uidata: UiData): + # raise DeprecationWarning('no use') + # # logger.debug('full_update') + # + # if self.__db_uid is not None and self.__db_uid != uidata.db_uid: + # logger.info('scheduler\'s database changed. resetting the view...') + # self.save_node_layout() + # self.clear() + # self.__db_uid = None + # self.__nodes_table_name = None + # # this means we probably reconnected to another scheduler, so existing nodes need to be dropped + # + # if self.__db_uid is None: + # self.__db_uid = uidata.db_uid + # self.__nodes_table_name = f'nodes_{self.__db_uid}' + # with sqlite3.connect(self.__db_path) as con: + # con.executescript(sql_init_script_nodes.format(db_uid=self.__db_uid)) + # + # to_del = [] + # to_del_tasks = {} + # existing_node_ids: Dict[int, Node] = {} + # existing_conn_ids: Dict[int, NodeConnection] = {} + # existing_task_ids: Dict[int, Task] = {} + # _perf_total = 0.0 + # graph_data = uidata.graph_data + # with performance_measurer() as pm: + # for item in self.items(): + # if isinstance(item, Node): # TODO: unify this repeating code and move the setting attribs to after all elements are created + # if item.get_id() not in graph_data.nodes or item.node_type() != graph_data.nodes[item.get_id()].type: + # to_del.append(item) + # continue + # existing_node_ids[item.get_id()] = item + # # TODO: update all kind of attribs here, for now we just don't have any + # elif isinstance(item, NodeConnection): + # if item.get_id() not in graph_data.connections: + # to_del.append(item) + # continue + # existing_conn_ids[item.get_id()] = item + # # TODO: update all kind of attribs here, for now we just don't have any + # elif isinstance(item, Task): + # if item.get_id() not in uidata.tasks.tasks: + # to_del.append(item) + # if item.node() is not None: + # if not item.node() in to_del_tasks: + # to_del_tasks[item.node()] = [] + # to_del_tasks[item.node()].append(item) + # continue + # existing_task_ids[item.get_id()] = item + # _perf_item_classify = pm.elapsed() + # _perf_total += pm.elapsed() + # + # # before we delete everything - we'll remove tasks from nodes to avoid deleting tasks one by one triggering tonns of animation + # with performance_measurer() as pm: + # for node, tasks in to_del_tasks.items(): + # node.remove_tasks(tasks) + # _perf_remove_tasks = pm.elapsed() + # _perf_total += pm.elapsed() + # with performance_measurer() as pm: + # for item in to_del: + # self.removeItem(item) + # _perf_remove_items = pm.elapsed() + # _perf_total += pm.elapsed() + # # removing items might cascade things, like removing node will remove connections to that node + # # so now we need to recheck existing items validity + # # though not consistent scene states should not come in uidata at all + # with performance_measurer() as pm: + # for existings in (existing_node_ids, existing_task_ids, existing_conn_ids): + # for item_id, item in tuple(existings.items()): + # if item.scene() != self: + # del existings[item_id] + # _perf_revalidate = pm.elapsed() + # _perf_total += pm.elapsed() + # + # nodes_to_layout = [] + # with performance_measurer() as pm: + # for id, new_node_data in graph_data.nodes.items(): + # if id in existing_node_ids: + # existing_node_ids[id].set_name(new_node_data.name) + # continue + # new_node = self.__scene_item_factory.make_node(self, id, new_node_data.type, new_node_data.name or f'node #{id}') + # try: + # new_node.setPos(*self.node_position(id)) + # except ValueError: + # nodes_to_layout.append(new_node) + # existing_node_ids[id] = new_node + # self.addItem(new_node) + # _perf_create_nodes = pm.elapsed() + # _perf_total += pm.elapsed() + # + # with performance_measurer() as pm: + # for id, new_conn_data in graph_data.connections.items(): + # if id in existing_conn_ids: + # # ensure connections + # innode, inname = existing_conn_ids[id].input() + # outnode, outname = existing_conn_ids[id].output() + # if innode.get_id() != new_conn_data.in_id or inname != new_conn_data.in_name: + # existing_conn_ids[id].set_input(existing_node_ids[new_conn_data.in_id], new_conn_data.in_name) + # existing_conn_ids[id].update() + # if outnode.get_id() != new_conn_data.out_id or outname != new_conn_data.out_name: + # existing_conn_ids[id].set_output(existing_node_ids[new_conn_data.out_id], new_conn_data.out_name) + # existing_conn_ids[id].update() + # continue + # new_conn = self.__scene_item_factory.make_node_connection( + # self, + # id, + # existing_node_ids[new_conn_data.out_id], + # existing_node_ids[new_conn_data.in_id], + # new_conn_data.out_name, new_conn_data.in_name + # ) + # existing_conn_ids[id] = new_conn + # self.addItem(new_conn) + # _perf_create_connections = pm.elapsed() + # _perf_total += pm.elapsed() + # + # with performance_measurer() as pm: + # for id, new_task_data in uidata.tasks.tasks.items(): + # if id not in existing_task_ids: + # new_task = self.__scene_item_factory.make_task(self, new_task_data) + # existing_task_ids[id] = new_task + # if new_task_data.split_origin_task_id is not None and new_task_data.split_origin_task_id in existing_task_ids: # TODO: bug: this and below will only work if parent/original tasks were created during previous updates + # origin_task = existing_task_ids[new_task_data.split_origin_task_id] + # new_task.setPos(origin_task.scenePos()) + # elif new_task_data.parent_id is not None and new_task_data.parent_id in existing_task_ids: + # origin_task = existing_task_ids[new_task_data.parent_id] + # new_task.setPos(origin_task.scenePos()) + # self.addItem(new_task) + # task = existing_task_ids[id] + # existing_node_ids[new_task_data.node_id].add_task(task) + # task.set_task_data(new_task_data) + # _perf_create_tasks = pm.elapsed() + # _perf_total += pm.elapsed() + # + # # now layout nodes that need it + # with performance_measurer() as pm: + # if nodes_to_layout: + # self.layout_nodes(nodes_to_layout) + # _perf_layout = pm.elapsed() + # _perf_total += pm.elapsed() + # + # with performance_measurer() as pm: + # if self.__all_task_groups != uidata.task_groups: + # self.__all_task_groups = uidata.task_groups + # self.task_groups_updated.emit(uidata.task_groups) + # _perf_task_groups_update = pm.elapsed() + # _perf_total += pm.elapsed() + # + # if _perf_total > 0.04: # arbitrary threshold ~ 1/25 of a sec + # logger.debug(f'update performed:\n' + # f'{_perf_item_classify:.04f}:\tclassify\n' + # f'{_perf_remove_tasks:.04f}:\tremove tasks\n' + # f'{_perf_remove_items:.04f}:\tremove items\n' + # f'{_perf_revalidate:.04f}:\trevalidate\n' + # f'{_perf_create_nodes:.04f}:\tcreate nodes\n' + # f'{_perf_create_connections:.04f}:\tcreate connections\n' + # f'{_perf_create_tasks:.04f}:\tcreate tasks\n' + # f'{_perf_layout:.04f}:\tlayout\n' + # f'{_perf_task_groups_update:.04f}:\ttask group update') @Slot(object, object, bool, object) def log_fetched(self, task_id: int, log: Dict[int, Dict[int, Union[IncompleteInvocationLogData, InvocationLogData]]], full_update, data: Optional["LongOperationData"] = None): @@ -1073,7 +1045,7 @@ def _node_default_settings_set(self, type_name: str, settings_name: Optional[str @Slot(int, str, str, object, object) def _node_created(self, node_id, node_type, node_name, pos, data: Optional["LongOperationData"] = None): - node = Node(node_id, node_type, node_name) + node = self.__scene_item_factory.make_node(self, node_id, node_type, node_name) node.setPos(pos) self.addItem(node) if data is not None: @@ -1124,7 +1096,7 @@ def _node_connections_added(self, cons: List[Tuple[int, int, str, int, str]], da innode = self.get_node(innode_id) if outnode is None or innode is None: return - new_conn = NodeConnection(new_id, outnode, innode, outname, inname) + new_conn = self.__scene_item_factory.make_node_connection(self, new_id, outnode, innode, outname, inname) self.addItem(new_conn) if data is not None: data.data = ([x[0] for x in cons],) @@ -1167,41 +1139,6 @@ def _nodepreset_fetched(self, package: str, preset: str, snippet: NodeSnippetDat data.data = (package, preset, snippet) self.process_operation(data) - def addItem(self, item): - logger.debug('adding item %s', item) - super(QGraphicsImguiScene, self).addItem(item) - if isinstance(item, Task): - self.__task_dict[item.get_id()] = item - elif isinstance(item, Node): - self.__node_dict[item.get_id()] = item - elif isinstance(item, NodeConnection): - self.__node_connections_dict[item.get_id()] = item - logger.debug('added item') - - def removeItem(self, item): - logger.debug('removing item %s', item) - if item.scene() != self: - logger.debug('item was already removed, just removing ids from internal caches') - else: - super(QGraphicsImguiScene, self).removeItem(item) - if isinstance(item, Task): - assert item.get_id() in self.__task_dict, 'inconsistency in internal caches. maybe item was doubleremoved?' - del self.__task_dict[item.get_id()] - elif isinstance(item, Node): - assert item.get_id() in self.__node_dict, 'inconsistency in internal caches. maybe item was doubleremoved?' - del self.__node_dict[item.get_id()] - elif isinstance(item, NodeConnection): - assert item.get_id() in self.__node_connections_dict - self.__node_connections_dict.pop(item.get_id()) - logger.debug('item removed') - - def clear(self): - logger.debug('clearing the scene...') - super(QGraphicsImguiScene, self).clear() - self.__task_dict = {} - self.__node_dict = {} - logger.debug('scene cleared') - @Slot(NodeSnippetData, QPointF) def nodes_from_snippet(self, snippet: NodeSnippetData, pos: QPointF, containing_long_op: Optional[LongOperation] = None): op = CreateNodesOp(self, snippet, pos) @@ -1333,37 +1270,11 @@ def _op_status_list(ops) -> Tuple[Tuple[int, Tuple[Optional[float], str]], ...]: # query # - def get_task(self, task_id) -> Optional[Task]: - return self.__task_dict.get(task_id, None) - - def get_node(self, node_id) -> Optional[Node]: - return self.__node_dict.get(node_id, None) - def get_node_by_session_id(self, node_session_id) -> Optional[Node]: node_id = self._session_node_id_to_id(node_session_id) if node_id is None: return None - return self.__node_dict.get(node_id, None) - - def get_node_connection(self, con_id) -> Optional[NodeConnection]: - return self.__node_connections_dict.get(con_id, None) - - def get_node_connection_from_ends(self, out_id, out_name, in_id, in_name) -> Optional[NodeConnection]: - for con in self.__node_connections_dict.values(): - onode, oname = con.output() - inode, iname = con.input() - if (onode.get_id(), oname) == (out_id, out_name) \ - and (inode.get_id(), iname) == (in_id, in_name): - return con - - def nodes(self) -> Tuple[Node, ...]: - return tuple(self.__node_dict.values()) - - def tasks(self) -> Tuple[Task, ...]: - return tuple(self.__task_dict.values()) - - def tasks_dict(self) -> Mapping[int, Task]: - return MappingProxyType(self.__task_dict) + return self.get_node(node_id, None) def find_nodes_by_name(self, name: str, match_partly=False) -> Set[Node]: if match_partly: @@ -1371,23 +1282,12 @@ def find_nodes_by_name(self, name: str, match_partly=False) -> Set[Node]: else: match_fn = lambda x, y: x == y matched = set() - for node in self.__node_dict.values(): + for node in self.nodes(): if match_fn(name, node.node_name()): matched.add(node) return matched - def get_inspected_item(self) -> Optional[QGraphicsItem]: - """ - returns item that needs to be inspected. - It's parameters should be displayed - generally, it's the first selected item - """ - sel = self.selectedItems() - if len(sel) == 0: - return None - return sel[0] - # # # diff --git a/src/lifeblood_viewer/graphics_scene_base.py b/src/lifeblood_viewer/graphics_scene_base.py new file mode 100644 index 00000000..7b6d8c75 --- /dev/null +++ b/src/lifeblood_viewer/graphics_scene_base.py @@ -0,0 +1,61 @@ +from .graphics_items.network_item import NetworkItem + +from PySide2.QtWidgets import QGraphicsScene, QWidget + +from typing import Optional + + +class GraphicsSceneBase(QGraphicsScene): + def __init__(self, parent: QWidget): + super().__init__(parent=parent) + self.__session_node_id_mapping = {} # for consistent redo-undo involving node creation/deletion, as node_id will change on repetition + self.__session_node_id_mapping_rev = {} + self.__next_session_node_id = -1 + + def get_inspected_item(self) -> Optional[NetworkItem]: + """ + returns item that needs to be inspected. + It's parameters should be displayed + generally, it's the first selected item + """ + sel = self.selectedItems() + if len(sel) == 0: + return None + return sel[0] + + # TODO: rename this shit - it should not really be aware of "node" concept here + def _session_node_id_to_id(self, session_id: int) -> Optional[int]: + """ + the whole idea of session id is to have it consistent through undo-redos + """ + node_id = self.__session_node_id_mapping.get(session_id) + if node_id is not None and self.get_node(node_id) is None: + self.__session_node_id_mapping.pop(session_id) + self.__session_node_id_mapping_rev.pop(node_id) + node_id = None + return node_id + + def _session_node_update_id(self, session_id: int, new_node_id: int): + prev_node_id = self.__session_node_id_mapping.get(session_id) + self.__session_node_id_mapping[session_id] = new_node_id + if prev_node_id is not None: + self.__session_node_id_mapping_rev.pop(prev_node_id) + self.__session_node_id_mapping_rev[new_node_id] = session_id + # TODO: self.__session_node_id_mapping should be cleared when undo stack is truncated, but so far it's a little "memory leak" + + def _session_node_update_session_id(self, new_session_id: int, node_id: int): + if new_session_id in self.__session_node_id_mapping: + raise RuntimeError(f'given session id {new_session_id} is already assigned') + old_session_id = self.__session_node_id_mapping_rev.get(node_id) + self.__session_node_id_mapping_rev[node_id] = new_session_id + if old_session_id is not None: + self.__session_node_id_mapping.pop(old_session_id) + self.__session_node_id_mapping[new_session_id] = node_id + + def _session_node_id_from_id(self, node_id: int): + if node_id not in self.__session_node_id_mapping_rev: + while self._session_node_id_to_id(self.__next_session_node_id) is not None: # they may be taken by pasted nodes + self.__next_session_node_id -= 1 + self._session_node_update_id(self.__next_session_node_id, node_id) + self.__next_session_node_id -= 1 + return self.__session_node_id_mapping_rev[node_id] diff --git a/src/lifeblood_viewer/graphics_scene_container.py b/src/lifeblood_viewer/graphics_scene_container.py new file mode 100644 index 00000000..fe58e66e --- /dev/null +++ b/src/lifeblood_viewer/graphics_scene_container.py @@ -0,0 +1,89 @@ +from lifeblood import logging +from .graphics_scene_base import GraphicsSceneBase +from .graphics_items import Node, Task, NodeConnection + +from types import MappingProxyType +from typing import Dict, Tuple, Mapping, Optional + +logger = logging.get_logger('viewer') + + +class GraphicsSceneWithNodesAndTasks(GraphicsSceneBase): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.__task_dict: Dict[int, Task] = {} + self.__node_dict: Dict[int, Node] = {} + self.__node_connections_dict: Dict[int, NodeConnection] = {} + + # settings: + self.__node_snapping_enabled = True + + def get_task(self, task_id) -> Optional[Task]: + return self.__task_dict.get(task_id, None) + + def get_node(self, node_id) -> Optional[Node]: + return self.__node_dict.get(node_id, None) + + def nodes(self) -> Tuple[Node, ...]: + return tuple(self.__node_dict.values()) + + def tasks(self) -> Tuple[Task, ...]: + return tuple(self.__task_dict.values()) + + def tasks_dict(self) -> Mapping[int, Task]: + return MappingProxyType(self.__task_dict) + + def get_node_connection(self, con_id) -> Optional[NodeConnection]: + return self.__node_connections_dict.get(con_id, None) + + def get_node_connection_from_ends(self, out_id, out_name, in_id, in_name) -> Optional[NodeConnection]: + for con in self.__node_connections_dict.values(): + onode, oname = con.output() + inode, iname = con.input() + if (onode.get_id(), oname) == (out_id, out_name) \ + and (inode.get_id(), iname) == (in_id, in_name): + return con + + def addItem(self, item): + logger.debug('adding item %s', item) + super().addItem(item) + assert item in self.items() + if isinstance(item, Task): + self.__task_dict[item.get_id()] = item + elif isinstance(item, Node): + self.__node_dict[item.get_id()] = item + elif isinstance(item, NodeConnection): + self.__node_connections_dict[item.get_id()] = item + logger.debug('added item') + + def removeItem(self, item): + logger.debug('removing item %s', item) + if item.scene() != self: + logger.debug('item was already removed, just removing ids from internal caches') + else: + super().removeItem(item) + if isinstance(item, Task): + assert item.get_id() in self.__task_dict, 'inconsistency in internal caches. maybe item was doubleremoved?' + del self.__task_dict[item.get_id()] + elif isinstance(item, Node): + assert item.get_id() in self.__node_dict, 'inconsistency in internal caches. maybe item was doubleremoved?' + del self.__node_dict[item.get_id()] + elif isinstance(item, NodeConnection): + assert item.get_id() in self.__node_connections_dict + self.__node_connections_dict.pop(item.get_id()) + logger.debug('item removed') + + def clear(self): + logger.debug('clearing the scene...') + super().clear() + self.__task_dict = {} + self.__node_dict = {} + logger.debug('scene cleared') + + # settings # TODO: move to a dedicated settings provider + + def node_snapping_enabled(self): + return self.__node_snapping_enabled + + def set_node_snapping_enabled(self, enabled: bool): + self.__node_snapping_enabled = enabled diff --git a/src/lifeblood_viewer/graphics_scene_viewing_widget.py b/src/lifeblood_viewer/graphics_scene_viewing_widget.py new file mode 100644 index 00000000..232ea20e --- /dev/null +++ b/src/lifeblood_viewer/graphics_scene_viewing_widget.py @@ -0,0 +1,12 @@ +from .graphics_items import NetworkItem + + +class GraphicsSceneViewingWidgetBase: + def request_ui_focus(self, item: NetworkItem): + raise NotImplementedError() + + def release_ui_focus(self, item: NetworkItem): + raise NotImplementedError() + + def item_requests_context_menu(self, item): + raise NotImplementedError() diff --git a/src/lifeblood_viewer/nodeeditor.py b/src/lifeblood_viewer/nodeeditor.py index ef58de3c..2d143dd0 100644 --- a/src/lifeblood_viewer/nodeeditor.py +++ b/src/lifeblood_viewer/nodeeditor.py @@ -13,6 +13,7 @@ from .ui_elements_base import ImguiWindow from .menu_entry_base import MainMenuLocation from .utils import BetterOrderedDict +from .graphics_scene_viewing_widget import GraphicsSceneViewingWidgetBase from lifeblood.base import TypeMetadata from lifeblood.misc import timeit from lifeblood.enums import TaskState @@ -36,6 +37,7 @@ from .save_node_settings_dialog import SaveNodeSettingsDialog from .nodeeditor_overlays.overlay_base import NodeEditorOverlayBase from .undo_stack import OperationCompletionDetails, OperationCompletionStatus +from .fancy_scene_item_factory import FancySceneItemFactory import imgui from .imgui_opengl_hotfix import AdjustedProgrammablePipelineRenderer as ProgrammablePipelineRenderer @@ -171,7 +173,7 @@ def enable_shortcuts(self): shortcut.setEnabled(True) -class NodeEditor(QGraphicsView, Shortcutable): +class NodeEditor(QGraphicsView, GraphicsSceneViewingWidgetBase, Shortcutable): def __init__(self, db_path: str = None, worker=None, parent=None): super(NodeEditor, self).__init__(parent=parent) # PySide's QWidget does not call super, so we call explicitly @@ -193,7 +195,11 @@ def __init__(self, db_path: str = None, worker=None, parent=None): self.__ui_focused_item = None - self.__scene = QGraphicsImguiScene(db_path, worker) + # TODO: refactor this + item_producer = FancySceneItemFactory(None) # TODO: split data controller from scene + self.__scene = QGraphicsImguiScene(item_producer, db_path, worker) + item_producer.set_data_controller(self.__scene) + self.setScene(self.__scene) #self.__update_timer = PySide2.QtCore.QTimer(self) #self.__update_timer.timeout.connect(lambda: self.__scene.invalidate(layers=QGraphicsScene.ForegroundLayer)) @@ -598,7 +604,13 @@ def scheduler_presets_metadata(self) -> MappingProxyType[str, Dict[str, TypeMeta # # - def show_task_menu(self, task, *, pos: Optional[QPoint] = None): + def item_requests_context_menu(self, item): + if isinstance(item, Task): + self.show_task_menu(item) + elif isinstance(item, Node): + self.show_node_menu(item) + + def show_task_menu(self, task: Task, *, pos: Optional[QPoint] = None): menu = QMenu(self) menu.addAction(f'task {task.get_id()}').setEnabled(False) menu.addSeparator() @@ -678,7 +690,7 @@ def show_node_menu(self, node: Node, pos=None): settings_menu = menu.addMenu('apply settings >') settings_menu.setEnabled(len(settings_names) > 0) for name in settings_names: - settings_menu.addAction(name).triggered.connect(lambda checked=False, x=node, sett=name: x.apply_settings(sett)) + settings_menu.addAction(name).triggered.connect(lambda checked=False, x=node.get_id(), sett=name: self.__scene.request_apply_node_settings(x, sett)) settings_actions_menu = menu.addMenu('modify settings >') settings_actions_menu.addAction('save settings').triggered.connect(lambda checked=False, x=node: self._popup_save_settings_dialog(x)) settings_defaults_menu = settings_actions_menu.addMenu('set defaults') @@ -686,11 +698,15 @@ def show_node_menu(self, node: Node, pos=None): settings_defaults_menu.addAction(name or '').triggered.connect(lambda checked=False, x=node, sett=name: self._popup_set_settings_default(node, sett)) menu.addSeparator() - menu.addAction('pause all tasks').triggered.connect(node.pause_all_tasks) - menu.addAction('resume all tasks').triggered.connect(node.resume_all_tasks) + menu.addAction('pause all tasks').triggered.connect(lambda: self.__scene.set_tasks_paused([x.get_id() for x in node.tasks_iter()], True)) + menu.addAction('resume all tasks').triggered.connect(lambda: self.__scene.set_tasks_paused([x.get_id() for x in node.tasks_iter()], False)) menu.addSeparator() - menu.addAction('regenerate all ready tasks').triggered.connect(node.regenerate_all_ready_tasks) - menu.addAction('retry all error tasks').triggered.connect(node.retry_all_error_tasks) + menu.addAction('regenerate all ready tasks').triggered.connect( + lambda: self.__scene.regenerate_all_ready_tasks_for_node(node.get_id()) + ) + menu.addAction('retry all error tasks').triggered.connect( + lambda: self.__scene.retry_all_error_tasks_for_node(node.get_id()) + ) menu.addSeparator() if len(self.__scene.selectedItems()) > 0: diff --git a/src/lifeblood_viewer/scene_data_controller.py b/src/lifeblood_viewer/scene_data_controller.py new file mode 100644 index 00000000..59504a08 --- /dev/null +++ b/src/lifeblood_viewer/scene_data_controller.py @@ -0,0 +1,174 @@ +from .undo_stack import UndoableOperation, OperationCompletionDetails +from .long_op import LongOperationData +from lifeblood.uidata import Parameter +from lifeblood.node_type_metadata import NodeTypeMetadata +from lifeblood.enums import TaskState, TaskGroupArchivedState +from lifeblood.taskspawn import NewTask +from PySide2.QtCore import QPointF + +from types import MappingProxyType +from typing import Any, Callable, Iterable, List, Optional, Set, Union + + +class SceneDataController: + def request_log(self, invocation_id: int, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def request_log_meta(self, task_id: int, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def request_attributes(self, task_id: int, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def request_invocation_job(self, task_id: int): + raise NotImplementedError() + + def request_node_ui(self, node_id: int): + raise NotImplementedError() + + def query_node_has_parameter(self, node_id: int, param_name: str, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def send_node_parameter_change(self, node_id: int, param: Parameter, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def send_node_parameter_expression_change(self, node_id: int, param: Parameter, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def _send_node_parameters_change(self, node_id: int, params: Iterable[Parameter], operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def request_apply_node_settings(self, node_id: int, settings_name: str, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def request_save_custom_settings(self, node_type_name: str, settings_name: str, settings: dict, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def request_set_settings_default(self, node_type_name: str, settings_name: Optional[str], operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def request_node_types_update(self): + raise NotImplementedError() + + def request_node_presets_update(self): + raise NotImplementedError() + + def request_node_preset(self, packagename: str, presetname: str, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def _request_set_node_name(self, node_id: int, name: str, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def request_node_connection_change(self, connection_id: int, outnode_id: Optional[int] = None, outname: Optional[str] = None, innode_id: Optional[int] = None, inname: Optional[str] = None): + raise NotImplementedError() + + def _request_node_connection_remove(self, connection_id: int, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def _request_node_connection_add(self, outnode_id: int, outname: str, innode_id: int, inname: str, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def _request_create_node(self, typename: str, nodename: str, pos: QPointF, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def request_remove_node(self, node_id: int, operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def _request_remove_nodes(self, node_ids: List[int], operation_data: Optional["LongOperationData"] = None): + raise NotImplementedError() + + def request_wipe_node(self, node_id: int): + raise NotImplementedError() + + def request_duplicate_nodes(self, node_ids: List[int], shift: QPointF): + raise NotImplementedError() + + def set_task_group_filter(self, groups): + raise NotImplementedError() + + def set_task_state(self, task_ids: List[int], state: TaskState): + raise NotImplementedError() + + def set_tasks_paused(self, task_ids_or_groups: List[Union[int, str]], paused: bool): + raise NotImplementedError() + + def set_task_group_archived_state(self, group_names: List[str], state: TaskGroupArchivedState): + raise NotImplementedError() + + def request_task_cancel(self, task_id: int): + raise NotImplementedError() + + def request_set_task_node(self, task_id: int, node_id: int): + raise NotImplementedError() + + def request_add_task(self, new_task: NewTask): + raise NotImplementedError() + + def request_rename_task(self, task_id: int, new_name: str): + raise NotImplementedError() + + def request_set_task_groups(self, task_id: int, new_groups: Set[str]): + raise NotImplementedError() + + def request_update_task_attributes(self, task_id: int, attribs_to_update: dict, attribs_to_delete: Set[str]): + raise NotImplementedError() + + def set_skip_dead(self, do_skip: bool) -> None: + raise NotImplementedError() + + def set_skip_archived_groups(self, do_skip: bool) -> None: + raise NotImplementedError() + + def request_set_environment_resolver_arguments(self, task_id, env_args): + raise NotImplementedError() + + def request_unset_environment_resolver_arguments(self, task_id): + raise NotImplementedError() + + # + + def request_graph_and_tasks_update(self): + """ + send a request to the scheduler to update node graph and tasks state immediately + """ + raise NotImplementedError() + + def request_task_groups_update(self): + """ + send a request to the scheduler to update task groups state immediately + """ + raise NotImplementedError() + + def request_workers_update(self): + """ + send a request to the scheduler to update workers state immediately + """ + raise NotImplementedError() + + def add_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + raise NotImplementedError() + + def cut_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + raise NotImplementedError() + + def change_connection_by_id(self, con_id, *, + to_outnode_id: Optional[int] = None, to_outname: Optional[str] = None, + to_innode_id: Optional[int] = None, to_inname: Optional[str] = None, + callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + raise NotImplementedError() + + def change_node_parameter(self, node_id: int, item: Parameter, value: Any = ..., expression=..., + *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + """ + + :param node_id: + :param item: + :param value: ... means no change + :param expression: ... means no change + :param callback: optional callback to call on successful completion of async operation + :return: + """ + raise NotImplementedError() + + def node_types(self) -> MappingProxyType[str, NodeTypeMetadata]: + raise NotImplementedError() diff --git a/src/lifeblood_viewer/scene_item_factory_base.py b/src/lifeblood_viewer/scene_item_factory_base.py new file mode 100644 index 00000000..f882915b --- /dev/null +++ b/src/lifeblood_viewer/scene_item_factory_base.py @@ -0,0 +1,14 @@ +from .graphics_items import Node, Task, NodeConnection +from .graphics_scene_base import GraphicsSceneBase +from lifeblood.ui_protocol_data import TaskData + + +class SceneItemFactoryBase: + def make_task(self, scene: GraphicsSceneBase, task_data: TaskData) -> Task: + raise NotImplementedError() + + def make_node(self, scene: GraphicsSceneBase, id: int, type: str, name: str) -> Node: + raise NotImplementedError() + + def make_node_connection(self, scene: GraphicsSceneBase, id: int, nodeout: Node, nodein: Node, outname: str, inname: str) -> NodeConnection: + raise NotImplementedError() From a955ea447db1908f53790bda6d9e6254b7a8903b Mon Sep 17 00:00:00 2001 From: pedohorse <13556996+pedohorse@users.noreply.github.com> Date: Fri, 13 Sep 2024 11:00:03 +0200 Subject: [PATCH 3/6] more graphics_items rearranging refactoring --- .../fancy_scene_item_factory.py | 14 +- .../dataaware_graphics_items.py | 1662 ----------------- .../graphics_items/graphics_items.py | 34 +- .../graphics_items/network_item.py | 11 +- .../node_connection_snap_point.py | 29 + .../graphics_items/node_extra_items.py | 44 - .../graphics_items/pretty_items/__init__.py | 0 .../pretty_items/decorated_node.py | 21 + .../pretty_items/drawable_node.py | 432 +++++ .../pretty_items/drawable_task.py | 258 +++ .../pretty_items/fancy_items/__init__.py | 0 .../fancy_items/implicit_split_visualizer.py | 70 + .../pretty_items/fancy_items/scene_node.py | 409 ++++ .../fancy_items/scene_node_connection.py | 265 +++ .../pretty_items/fancy_items/scene_task.py | 195 ++ .../fancy_items/scene_task_preview.py | 46 + .../node_connection_create_preview.py | 135 ++ .../pretty_items/node_decorator_base.py | 23 + .../pretty_items/task_animation.py | 42 + .../graphics_items/qextended_graphics_item.py | 17 + .../graphics_items/scene_network_item.py | 31 + src/lifeblood_viewer/graphics_scene.py | 58 +- src/lifeblood_viewer/scene_data_controller.py | 45 +- src/lifeblood_viewer/scene_ops.py | 24 +- 24 files changed, 2054 insertions(+), 1811 deletions(-) delete mode 100644 src/lifeblood_viewer/graphics_items/dataaware_graphics_items.py create mode 100644 src/lifeblood_viewer/graphics_items/node_connection_snap_point.py delete mode 100644 src/lifeblood_viewer/graphics_items/node_extra_items.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/__init__.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/decorated_node.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/drawable_node.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/drawable_task.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/__init__.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/implicit_split_visualizer.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node_connection.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_task.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_task_preview.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/node_connection_create_preview.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/node_decorator_base.py create mode 100644 src/lifeblood_viewer/graphics_items/pretty_items/task_animation.py create mode 100644 src/lifeblood_viewer/graphics_items/qextended_graphics_item.py create mode 100644 src/lifeblood_viewer/graphics_items/scene_network_item.py diff --git a/src/lifeblood_viewer/fancy_scene_item_factory.py b/src/lifeblood_viewer/fancy_scene_item_factory.py index e9a080f4..6a9e8c73 100644 --- a/src/lifeblood_viewer/fancy_scene_item_factory.py +++ b/src/lifeblood_viewer/fancy_scene_item_factory.py @@ -4,7 +4,17 @@ from .scene_data_controller import SceneDataController from .scene_item_factory_base import SceneItemFactoryBase -from .graphics_items.dataaware_graphics_items import SceneNode, SceneTask, SceneNodeConnection +from .graphics_items.pretty_items.fancy_items.scene_node import SceneNode +from .graphics_items.pretty_items.fancy_items.scene_node_connection import SceneNodeConnection +from .graphics_items.pretty_items.fancy_items.scene_task import SceneTask +from .graphics_items.pretty_items.fancy_items.implicit_split_visualizer import ImplicitSplitVisualizer +from .graphics_items.pretty_items.node_decorator_base import NodeDecoratorFactoryBase, NodeDecorator +from .graphics_items.pretty_items.drawable_node import DrawableNode + + +class FancyNodeDecoratorFactory(NodeDecoratorFactoryBase): + def make_decorator(self, node: DrawableNode) -> NodeDecorator: + return ImplicitSplitVisualizer(node) class FancySceneItemFactory(SceneItemFactoryBase): @@ -18,7 +28,7 @@ def make_task(self, scene: GraphicsSceneBase, task_data: TaskData) -> Task: return SceneTask(scene, task_data, self.__data_controller) def make_node(self, scene: GraphicsSceneBase, id: int, type: str, name: str) -> Node: - return SceneNode(scene, id, type, name, self.__data_controller) + return SceneNode(scene, id, type, name, self.__data_controller, [FancyNodeDecoratorFactory()]) def make_node_connection(self, scene: GraphicsSceneBase, id: int, nodeout: Node, nodein: Node, outname: str, inname: str) -> NodeConnection: return SceneNodeConnection(scene, id, nodeout, nodein, outname, inname, self.__data_controller) diff --git a/src/lifeblood_viewer/graphics_items/dataaware_graphics_items.py b/src/lifeblood_viewer/graphics_items/dataaware_graphics_items.py deleted file mode 100644 index 2c52819b..00000000 --- a/src/lifeblood_viewer/graphics_items/dataaware_graphics_items.py +++ /dev/null @@ -1,1662 +0,0 @@ -from datetime import timedelta -from math import sqrt -import imgui -from lifeblood import logging -from lifeblood.config import get_config -from lifeblood.enums import TaskState, NodeParameterType, InvocationState -from lifeblood.uidata import CollapsableVerticalGroup, OneLineParametersLayout, Parameter, ParameterExpressionError, ParametersLayoutBase, Separator, NodeUi -from lifeblood.ui_protocol_data import TaskData, IncompleteInvocationLogData, InvocationLogData -from .graphics_items import Node, NodeConnection, Task -from .network_item_watchers import NetworkItemWatcher -from .node_extra_items import ImplicitSplitVisualizer -from .utils import call_later, length2 -from ..editor_scene_integration import fetch_and_open_log_viewer -from ..scene_data_controller import SceneDataController -from ..code_editor.editor import StringParameterEditor -from ..graphics_scene_container import GraphicsSceneWithNodesAndTasks -from ..graphics_scene_viewing_widget import GraphicsSceneViewingWidgetBase - -from PySide2.QtCore import QAbstractAnimation, Qt, Slot, QPointF, QRectF, QSizeF, QSequentialAnimationGroup -from PySide2.QtGui import QBrush, QColor, QDesktopServices, QLinearGradient, QPainter, QPainterPath, QPainterPathStroker, QPen -from PySide2.QtWidgets import QGraphicsItem, QStyleOptionGraphicsItem, QGraphicsSceneMouseEvent, QWidget - -from typing import Callable, Iterable, List, Optional, Set, Tuple - - -logger = logging.get_logger('viewer') - - -class SnapPoint: - def pos(self) -> QPointF: - raise NotImplementedError() - - -class NodeConnSnapPoint(SnapPoint): - def __init__(self, node: Node, connection_name: str, connection_is_input: bool): - super().__init__() - self.__node = node - self.__conn_name = connection_name - self.__isinput = connection_is_input - - def node(self) -> Node: - return self.__node - - def connection_name(self) -> str: - return self.__conn_name - - def connection_is_input(self) -> bool: - return self.__isinput - - def pos(self) -> QPointF: - if self.__isinput: - return self.__node.get_input_position(self.__conn_name) - return self.__node.get_output_position(self.__conn_name) - - -class TaskAnimation(QAbstractAnimation): - def __init__(self, task: "Task", node1: "Node", pos1: "QPointF", node2: "Node", pos2: "QPointF", duration: int, parent): - super().__init__(parent) - self.__task = task - - self.__node1 = node1 - self.__pos1 = pos1 - self.__node2 = node2 - self.__pos2 = pos2 - self.__duration = max(duration, 1) - self.__started = False - self.__anim_type = 0 if self.__node1 is self.__node2 else 1 - - def duration(self) -> int: - return self.__duration - - def updateCurrentTime(self, currentTime: int) -> None: - if not self.__started: - self.__started = True - - pos1 = self.__pos1 - if self.__node1: - pos1 = self.__node1.mapToScene(pos1) - - pos2 = self.__pos2 - if self.__node2: - pos2 = self.__node2.mapToScene(pos2) - - t = currentTime / self.duration() - if self.__anim_type == 0: # linear - pos = pos1 * (1 - t) + pos2 * t - else: # cubic - curv = min((pos2-pos1).manhattanLength() * 2, 1000) # 1000 is kinda derivative - a = QPointF(0, curv) - (pos2-pos1) - b = QPointF(0, -curv) + (pos2-pos1) - pos = pos1*(1-t) + pos2*t + t*(1-t)*(a*(1-t) + b*t) - self.__task.setPos(pos) - - -class SceneNode(Node): - base_height = 100 - base_width = 150 - - def __init__(self, scene: GraphicsSceneWithNodesAndTasks, id: int, type: str, name: str, data_controller: SceneDataController): - super().__init__(scene, id, type, name) - self.__scene_container = scene - self.__data_controller: SceneDataController = data_controller - self.__visual_tasks: List[Task] = [] - - # display - self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemSendsGeometryChanges) - self.setAcceptHoverEvents(True) - self.__nodeui_menucache = {} - self.__ui_selected_tab = 0 - - self.__hoverover_pos: Optional[QPointF] = None - self.__height = self.base_height - self.__width = self.base_width - self.__pivot_x = 0 - self.__pivot_y = 0 - - self.__ui_interactor = None - self.__ui_grabbed_conn = None - self.__ui_widget: Optional[GraphicsSceneViewingWidgetBase] = None - - self.__move_start_position = None - self.__move_start_selection = None - - self.__input_radius = 12 - self.__input_visible_radius = 8 - self.__line_width = 1 - - self.__node_ui_for_io_requested = False - - # prepare default drawing tools - self.__borderpen = QPen(QColor(96, 96, 96, 255)) - self.__borderpen_selected = QPen(QColor(144, 144, 144, 255)) - self.__caption_pen = QPen(QColor(192, 192, 192, 255)) - self.__typename_pen = QPen(QColor(128, 128, 128, 192)) - self.__borderpen.setWidthF(self.__line_width) - self.__header_brush = QBrush(QColor(48, 64, 48, 192)) - self.__body_brush = QBrush(QColor(48, 48, 48, 128)) - self.__connector_brush = QBrush(QColor(48, 48, 48, 192)) - self.__connector_brush_hovered = QBrush(QColor(96, 96, 96, 128)) - - self.__expanded = False - - self.__cached_bounds = None - self.__cached_nodeshape = None - self.__cached_bodymask = None - self.__cached_headershape = None - self.__cached_bodyshape = None - self.__cached_expandbutton_shape = None - - # misc - self.__manual_url_base = get_config('viewer').get_option_noasync('manual_base_url', 'https://pedohorse.github.io/lifeblood') - - # children! - self.__vismark = ImplicitSplitVisualizer(self) - self.__vismark.setPos(QPointF(0, self._get_nodeshape().boundingRect().height() * 0.5)) - self.__vismark.setZValue(-2) - - def apply_settings(self, settings_name: str): - self.__data_controller.request_apply_node_settings(self.get_id(), settings_name) - - def pause_all_tasks(self): - self.__data_controller.set_tasks_paused([x.get_id() for x in self.tasks_iter()], True) - - def resume_all_tasks(self): - self.__data_controller.set_tasks_paused([x.get_id() for x in self.tasks_iter()], False) - - def update_nodeui(self, nodeui: NodeUi): - super().update_nodeui(nodeui) - self.__nodeui_menucache = {} - - def set_expanded(self, expanded: bool): - if self.__expanded == expanded: - return - self.__expanded = expanded - self.prepareGeometryChange() - self.__height = self.base_height - if expanded: - self.__height += 225 - self.__pivot_y -= 225/2 - # self.setPos(self.pos() + QPointF(0, 225*0.5)) - else: - self.__pivot_y = 0 - # self.setPos(self.pos() - QPointF(0, 225 * 0.5)) # TODO: modify painterpath getters to avoid moving nodes on expand - self.__vismark.setPos(QPointF(0, self._get_nodeshape().boundingRect().height() * 0.5)) - - for i, task in enumerate(self.tasks()): - self.__make_task_child_with_position(task, *self.get_task_pos(task, i), animate=True) - - def get_input_position(self, name: str = 'main') -> QPointF: - if not self.input_names(): - idx = 0 - cnt = 1 - elif name not in self.input_names(): - raise RuntimeError(f'unexpected input name {name}') - else: - idx = self.input_names().index(name) - cnt = len(self.input_names()) - assert cnt > 0 - return self.mapToScene(-0.5 * self.__width + (idx + 1) * self.__width/(cnt + 1) - self.__pivot_x, - -0.5 * self.__height - self.__pivot_y) - - def get_output_position(self, name: str = 'main') -> QPointF: - if not self.output_names(): - idx = 0 - cnt = 1 - elif name not in self.output_names(): - raise RuntimeError(f'unexpected output name {name} , {self.output_names()}') - else: - idx = self.output_names().index(name) - cnt = len(self.output_names()) - assert cnt > 0 - return self.mapToScene(-0.5 * self.__width + (idx + 1) * self.__width/(cnt + 1) - self.__pivot_x, - 0.5 * self.__height - self.__pivot_y) - - def input_snap_points(self): - # TODO: cache snap points, don't recalc them every time - if self.get_nodeui() is None: - return [] - inputs = [] - for input_name in self.get_nodeui().inputs_names(): - inputs.append(NodeConnSnapPoint(self, input_name, True)) - return inputs - - def output_snap_points(self): - # TODO: cache snap points, don't recalc them every time - if self.get_nodeui() is None: - return [] - outputs = [] - for output_name in self.get_nodeui().outputs_names(): - outputs.append(NodeConnSnapPoint(self, output_name, False)) - return outputs - - - # move animation - - def get_task_pos(self, task: "Task", pos_id: int) -> Tuple[QPointF, int]: - rect = self._get_bodyshape().boundingRect() - x, y = rect.topLeft().toTuple() - w, h = rect.size().toTuple() - d = task.draw_size() # TODO: this assumes size is same, so dont make it an instance method - r = d * 0.5 - - #w *= 0.5 - x += r - y += r - h -= d - w -= d - x += (d * pos_id % w) - y_shift = d * int(d * pos_id / w) - y += (y_shift % h) - return QPointF(x, y), int(y_shift / h) - - def __make_task_child_with_position(self, task: "Task", pos: QPointF, layer: int, *, animate: bool = False): - """ - helper function that actually changes parent of a task and initializes animations if needed - """ - assert isinstance(task, SceneTask) # TODO: hmmm - if animate: - task.append_task_move_animation(self, pos, layer) - else: - task.set_task_position(self, pos, layer) - - def add_task(self, task: "Task"): - if task in self.__visual_tasks: - assert task in self.tasks() - return - - # the animated part - pos_id = len(self.__visual_tasks) - if task.node() is None: - self.__make_task_child_with_position(task, *self.get_task_pos(task, pos_id)) - else: - self.__make_task_child_with_position(task, *self.get_task_pos(task, pos_id), animate=True) - - super().add_task(task) - insert_at = self._find_insert_index_for_task(task, prefer_back=True) - - self.__visual_tasks.append(None) # temporary placeholder, it'll be eliminated either in the loop, or after if task is last - for i in reversed(range(insert_at + 1, len(self.__visual_tasks))): - self.__visual_tasks[i] = self.__visual_tasks[i - 1] # TODO: animated param should affect below! - self.__make_task_child_with_position(self.__visual_tasks[i], *self.get_task_pos(self.__visual_tasks[i], i), animate=True) - self.__visual_tasks[insert_at] = task - self.__make_task_child_with_position(self.__visual_tasks[insert_at], *self.get_task_pos(task, insert_at), animate=True) - - def remove_tasks(self, tasks_to_remove: Iterable["Task"]): - tasks_to_remove = set(tasks_to_remove) - super().remove_tasks(tasks_to_remove) - - self.__visual_tasks: List["Task"] = [None if x in tasks_to_remove else x for x in self.__visual_tasks] - off = 0 - for i, task in enumerate(self.__visual_tasks): - if task is None: - off += 1 - else: - self.__visual_tasks[i - off] = self.__visual_tasks[i] - self.__make_task_child_with_position(self.__visual_tasks[i - off], *self.get_task_pos(self.__visual_tasks[i - off], i - off), animate=True) - self.__visual_tasks = self.__visual_tasks[:-off] - for x in tasks_to_remove: - assert x not in self.__visual_tasks - - def remove_task(self, task_to_remove: "Task"): - super().remove_task(task_to_remove) - task_pid = self.__visual_tasks.index(task_to_remove) - - for i in range(task_pid, len(self.__visual_tasks) - 1): - self.__visual_tasks[i] = self.__visual_tasks[i + 1] - self.__make_task_child_with_position(self.__visual_tasks[i], *self.get_task_pos(self.__visual_tasks[i], i), animate=True) - self.__visual_tasks = self.__visual_tasks[:-1] - assert task_to_remove not in self.__visual_tasks - self.item_updated(redraw=True, ui=False) # cuz node displays task number - we should redraw - - def _find_insert_index_for_task(self, task, prefer_back=False): - if task.state() == TaskState.IN_PROGRESS and not prefer_back: - return 0 - - if task.state() != TaskState.IN_PROGRESS and prefer_back: - return len(self.__visual_tasks) - - # now fun thing: we either have IN_PROGRESS and prefer_back, or NOT IN_PROGRESS and NOT prefer_back - # and both cases have the same logic for position finding - for i, task in enumerate(self.__visual_tasks): - if task.state() != TaskState.IN_PROGRESS: - return i - else: - return len(self.__visual_tasks) - - def task_state_changed(self, task): - """ - here node might decide to highlight the task that changed state one way or another - """ - if task.state() not in (TaskState.IN_PROGRESS, TaskState.GENERATING, TaskState.POST_GENERATING): - return - - # find a place - append_at = self._find_insert_index_for_task(task) - - if append_at == len(self.__visual_tasks): # this is impossible case (in current impl of _find_insert_index_for_task) (cuz task is in __visual_tasks, and it's not in IN_PROGRESS) - return - - idx = self.__visual_tasks.index(task) - if idx <= append_at: # already in place (and ignore moving further - return - - # place where it has to be - for i in reversed(range(append_at + 1, idx+1)): - self.__visual_tasks[i] = self.__visual_tasks[i-1] - self.__make_task_child_with_position(self.__visual_tasks[i], *self.get_task_pos(self.__visual_tasks[i], i), animate=True) - self.__visual_tasks[append_at] = task - self.__make_task_child_with_position(self.__visual_tasks[append_at], *self.get_task_pos(task, append_at), animate=True) - - # - # interface - - # helper - def __draw_single_item(self, item, size=(1.0, 1.0), drawing_widget=None): - if isinstance(item, Parameter): - if not item.visible(): - return - param_name = item.name() - param_label = item.label() or '' - parent_layout = item.parent() - idstr = f'_{self.get_id()}' - assert isinstance(parent_layout, ParametersLayoutBase) - imgui.push_item_width(imgui.get_window_width() * parent_layout.relative_size_for_child(item)[0] * 2 / 3) - - changed = False - expr_changed = False - - new_item_val = None - new_item_expression = None - - try: - if item.has_expression(): - with imgui.colored(imgui.COLOR_FRAME_BACKGROUND, 0.1, 0.4, 0.1): - expr_changed, newval = imgui.input_text('##'.join((param_label, param_name, idstr)), item.expression(), 256, flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) - if expr_changed: - new_item_expression = newval - elif item.has_menu(): - menu_order, menu_items = item.get_menu_items() - - if param_name not in self.__nodeui_menucache: - self.__nodeui_menucache[param_name] = {'menu_items_inv': {v: k for k, v in menu_items.items()}, - 'menu_order_inv': {v: i for i, v in enumerate(menu_order)}} - - menu_items_inv = self.__nodeui_menucache[param_name]['menu_items_inv'] - menu_order_inv = self.__nodeui_menucache[param_name]['menu_order_inv'] - if item.is_readonly() or item.is_locked(): # TODO: treat locked items somehow different, but for now it's fine - imgui.text(menu_items_inv[item.value()]) - return - else: - changed, val = imgui.combo('##'.join((param_label, param_name, idstr)), menu_order_inv[menu_items_inv[item.value()]], menu_order) - if changed: - new_item_val = menu_items[menu_order[val]] - else: - if item.is_readonly() or item.is_locked(): # TODO: treat locked items somehow different, but for now it's fine - imgui.text(f'{item.value()}') - if item.label(): - imgui.same_line() - imgui.text(f'{item.label()}') - return - param_type = item.type() - if param_type == NodeParameterType.BOOL: - changed, newval = imgui.checkbox('##'.join((param_label, param_name, idstr)), item.value()) - elif param_type == NodeParameterType.INT: - #changed, newval = imgui.slider_int('##'.join((param_label, param_name, idstr)), item.value(), 0, 10) - slider_limits = item.display_value_limits() - if slider_limits[0] is not None: - changed, newval = imgui.slider_int('##'.join((param_label, param_name, idstr)), item.value(), *slider_limits) - else: - changed, newval = imgui.input_int('##'.join((param_label, param_name, idstr)), item.value(), flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) - if imgui.begin_popup_context_item(f'item context menu##{param_name}', 2): - imgui.selectable('toggle expression') - imgui.end_popup() - elif param_type == NodeParameterType.FLOAT: - #changed, newval = imgui.slider_float('##'.join((param_label, param_name, idstr)), item.value(), 0, 10) - slider_limits = item.display_value_limits() - if slider_limits[0] is not None and slider_limits[1] is not None: - changed, newval = imgui.slider_float('##'.join((param_label, param_name, idstr)), item.value(), *slider_limits) - else: - changed, newval = imgui.input_float('##'.join((param_label, param_name, idstr)), item.value(), flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) - elif param_type == NodeParameterType.STRING: - if item.is_text_multiline(): - # TODO: this below is a temporary solution. it only gives 8192 extra symbols for editing, but currently there is no proper way around with current pyimgui version - imgui.begin_group() - ed_butt_pressed = imgui.small_button(f'open in external window##{param_name}') - changed, newval = imgui.input_text_multiline('##'.join((param_label, param_name, idstr)), item.unexpanded_value(), len(item.unexpanded_value()) + 1024*8, flags=imgui.INPUT_TEXT_ALLOW_TAB_INPUT | imgui.INPUT_TEXT_ENTER_RETURNS_TRUE | imgui.INPUT_TEXT_CTRL_ENTER_FOR_NEW_LINE) - imgui.end_group() - if ed_butt_pressed: - hl = StringParameterEditor.SyntaxHighlight.NO_HIGHLIGHT - if item.syntax_hint() == 'python': - hl = StringParameterEditor.SyntaxHighlight.PYTHON - wgt = StringParameterEditor(syntax_highlight=hl, parent=drawing_widget) - wgt.setAttribute(Qt.WA_DeleteOnClose, True) - wgt.set_text(item.unexpanded_value()) - wgt.edit_done.connect(lambda x, sc=self.scene(), id=self.get_id(), it=item: sc.change_node_parameter(id, item, x)) - wgt.set_title(f'editing parameter "{param_name}"') - wgt.show() - else: - changed, newval = imgui.input_text('##'.join((param_label, param_name, idstr)), item.unexpanded_value(), 256, flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) - else: - raise NotImplementedError() - if changed: - new_item_val = newval - - # item context menu popup - popupid = '##'.join((param_label, param_name, idstr)) # just to make sure no names will collide with full param imgui lables - if imgui.begin_popup_context_item(f'Item Context Menu##{popupid}', 2): - if item.can_have_expressions() and not item.has_expression(): - if imgui.selectable(f'enable expression##{popupid}')[0]: - expr_changed = True - # try to turn backtick expressions into normal one - if item.type() == NodeParameterType.STRING: - new_item_expression = item.python_from_expandable_string(item.unexpanded_value()) - else: - new_item_expression = str(item.value()) - if item.has_expression(): - if imgui.selectable(f'delete expression##{popupid}')[0]: - try: - value = item.value() - except ParameterExpressionError as e: - value = item.default_value() - expr_changed = True - changed = True - new_item_val = value - new_item_expression = None - imgui.end_popup() - finally: - imgui.pop_item_width() - - if changed or expr_changed: - # TODO: op below may fail, so callback to display error should be provided - self.__data_controller.change_node_parameter(self.get_id(), item, - new_item_val if changed else ..., - new_item_expression if expr_changed else ...) - - elif isinstance(item, Separator): - imgui.separator() - elif isinstance(item, OneLineParametersLayout): - first_time = True - for child in item.items(recursive=False): - h, w = item.relative_size_for_child(child) - if isinstance(child, Parameter): - if not child.visible(): - continue - if first_time: - first_time = False - else: - imgui.same_line() - self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) - elif isinstance(item, CollapsableVerticalGroup): - expanded, _ = imgui.collapsing_header(f'{item.label()}##{item.name()}') - if expanded: - imgui.indent(5) - for child in item.items(recursive=False): - h, w = item.relative_size_for_child(child) - self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) - imgui.unindent(5) - imgui.separator() - elif isinstance(item, ParametersLayoutBase): - imgui.indent(5) - for child in item.items(recursive=False): - h, w = item.relative_size_for_child(child) - if isinstance(child, Parameter): - if not child.visible(): - continue - self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) - imgui.unindent(5) - elif isinstance(item, ParametersLayoutBase): - for child in item.items(recursive=False): - h, w = item.relative_size_for_child(child) - if isinstance(child, Parameter): - if not child.visible(): - continue - self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) - else: - raise NotImplementedError(f'unknown parameter hierarchy item to display {type(item)}') - - # main dude - def draw_imgui_elements(self, drawing_widget): - imgui.text(f'Node {self.get_id()}, type "{self.node_type()}", name {self.node_name()}') - - if imgui.selectable(f'parameters##{self.node_name()}', self.__ui_selected_tab == 0, width=imgui.get_window_width() * 0.5 * 0.7)[1]: - self.__ui_selected_tab = 0 - imgui.same_line() - if imgui.selectable(f'description##{self.node_name()}', self.__ui_selected_tab == 1, width=imgui.get_window_width() * 0.5 * 0.7)[1]: - self.__ui_selected_tab = 1 - imgui.separator() - - if self.__ui_selected_tab == 0: - if (nodeui := self.get_nodeui()) is not None: - self.__draw_single_item(nodeui.main_parameter_layout(), drawing_widget=drawing_widget) - elif self.__ui_selected_tab == 1: - - if (node_type := self.node_type()) in self.__data_controller.node_types() and imgui.button('open manual page'): - plugin_info = self.__data_controller.node_types()[node_type].plugin_info - category = plugin_info.category - package = plugin_info.package_name - QDesktopServices.openUrl(self.__manual_url_base + f'/nodes/{category}{f"/{package}" if package else ""}/{self.node_type()}.html') - imgui.text(self.__data_controller.node_types()[self.node_type()].description if self.node_type() in self.__data_controller.node_types() else 'error') - - # - # scene item - # - - def boundingRect(self) -> QRectF: - if self.__cached_bounds is None: - lw = self.__width + self.__line_width - lh = self.__height + self.__line_width - self.__cached_bounds = QRectF( - -0.5 * lw - self.__pivot_x, - -0.5 * lh - (max(self.__input_radius, self.__input_visible_radius) + 0.5 * self.__line_width) - self.__pivot_y, - lw, - lh + 2 * (max(self.__input_radius, self.__input_visible_radius) + 0.5 * self.__line_width)) - return self.__cached_bounds - - def _get_nodeshape(self): - if self.__cached_nodeshape is None: - lw = self.__width + self.__line_width - lh = self.__height + self.__line_width - nodeshape = QPainterPath() - nodeshape.addRoundedRect(QRectF(-0.5 * lw - self.__pivot_x, -0.5 * lh - self.__pivot_y, lw, lh), 5, 5) - self.__cached_nodeshape = nodeshape - return self.__cached_nodeshape - - def _get_bodymask(self): - if self.__cached_bodymask is None: - lw = self.__width + self.__line_width - lh = self.__height + self.__line_width - bodymask = QPainterPath() - bodymask.addRect(-0.5 * lw - self.__pivot_x, -0.5 * lh + 32 - self.__pivot_y, lw, lh - 32) - self.__cached_bodymask = bodymask - return self.__cached_bodymask - - def _get_headershape(self): - if self.__cached_headershape is None: - self.__cached_headershape = self._get_nodeshape() - self._get_bodymask() - return self.__cached_headershape - - def _get_bodyshape(self): - if self.__cached_bodyshape is None: - self.__cached_bodyshape = self._get_nodeshape() & self._get_bodymask() - return self.__cached_bodyshape - - def _get_expandbutton_shape(self): - if self.__cached_expandbutton_shape is None: - bodyshape = self._get_bodyshape() - mask = QPainterPath() - body_bound = bodyshape.boundingRect() - corner = body_bound.bottomRight() + QPointF(15, 15) - top = corner + QPointF(0, -60) - left = corner + QPointF(-60, 0) - mask.moveTo(corner) - mask.lineTo(top) - mask.lineTo(left) - mask.lineTo(corner) - self.__cached_expandbutton_shape = bodyshape & mask - return self.__cached_expandbutton_shape - - def reanalyze_nodeui(self): - self.prepareGeometryChange() # not calling this seem to be able to break scene's internal index info on our connections - # bug that appears - on first scene load deleting a node with more than 1 input/output leads to crash - # on open nodes have 1 output, then they receive interface update and this func is called, and here's where bug may happen - - super().reanalyze_nodeui() - css = self.get_nodeui().color_scheme() - if css.secondary_color() is not None: - gradient = QLinearGradient(-self.__width*0.1, 0, self.__width*0.1, 16) - gradient.setColorAt(0.0, QColor(*(x * 255 for x in css.main_color()), 192)) - gradient.setColorAt(1.0, QColor(*(x * 255 for x in css.secondary_color()), 192)) - self.__header_brush = QBrush(gradient) - else: - self.__header_brush = QBrush(QColor(*(x * 255 for x in css.main_color()), 192)) - self.item_updated(redraw=True, ui=True) # cuz input count affects visualization in the graph - - def prepareGeometryChange(self): - super().prepareGeometryChange() - self.__cached_bounds = None - self.__cached_nodeshape = None - self.__cached_bodymask = None - self.__cached_headershape = None - self.__cached_bodyshape = None - self.__cached_expandbutton_shape = None - for conn in self.all_connections(): - conn.prepareGeometryChange() - - def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: - screen_rect = painter.worldTransform().mapRect(self.boundingRect()) - painter.pen().setWidthF(self.__line_width) - nodeshape = self._get_nodeshape() - - # this request from paint here is SUS - if not self.__node_ui_for_io_requested: - self.__node_ui_for_io_requested = True - self.__data_controller.request_node_ui(self.get_id()) - - if screen_rect.width() > 40: - ninputs = len(self.input_names()) - noutputs = len(self.output_names()) - r2 = (self.__input_radius + 0.5*self.__line_width)**2 - for fi in range(ninputs + noutputs): - path = QPainterPath() - is_inputs = fi < ninputs - i = fi if is_inputs else fi - ninputs - input_point = QPointF(-0.5 * self.__width + (i + 1) * self.__width/((ninputs if is_inputs else noutputs) + 1) - self.__pivot_x, - (-0.5 if is_inputs else 0.5) * self.__height - self.__pivot_y) - path.addEllipse(input_point, - self.__input_visible_radius, self.__input_visible_radius) - path -= nodeshape - pen = self.__borderpen - brush = self.__connector_brush - if self.__hoverover_pos is not None: - if QPointF.dotProduct(input_point - self.__hoverover_pos, input_point - self.__hoverover_pos) <= r2: - pen = self.__borderpen_selected - brush = self.__connector_brush_hovered - painter.setPen(pen) - painter.fillPath(path, brush) - painter.drawPath(path) - - headershape = self._get_headershape() - bodyshape = self._get_bodyshape() - - if self.isSelected(): - if screen_rect.width() > 100: - width_mult = 1 - elif screen_rect.width() > 50: - width_mult = 4 - elif screen_rect.width() > 25: - width_mult = 8 - else: - width_mult = 16 - self.__borderpen_selected.setWidth(self.__line_width*width_mult) - painter.setPen(self.__borderpen_selected) - else: - painter.setPen(self.__borderpen) - painter.fillPath(headershape, self.__header_brush) - painter.fillPath(bodyshape, self.__body_brush) - expand_button_shape = self._get_expandbutton_shape() - painter.fillPath(expand_button_shape, self.__header_brush) - painter.drawPath(nodeshape) - # draw highlighted elements on top - if self.__hoverover_pos and expand_button_shape.contains(self.__hoverover_pos): - painter.setPen(self.__borderpen_selected) - painter.drawPath(expand_button_shape) - - # draw header/text last - if screen_rect.width() > 50: - painter.setPen(self.__caption_pen) - painter.drawText(headershape.boundingRect(), Qt.AlignHCenter | Qt.AlignTop, self.node_name()) - painter.setPen(self.__typename_pen) - painter.drawText(headershape.boundingRect(), Qt.AlignRight | Qt.AlignBottom, self.node_type()) - painter.drawText(headershape.boundingRect(), Qt.AlignLeft | Qt.AlignBottom, f'{len(self.tasks())}') - - def itemChange(self, change, value): - if change == QGraphicsItem.ItemSelectedHasChanged: - if value and self.graphics_scene().get_inspected_item() == self: # item was just selected, And is the first selected - self.__data_controller.request_node_ui(self.get_id()) - elif change == QGraphicsItem.ItemPositionChange: - if self.__move_start_position is None: - self.__move_start_position = self.pos() - for connection in self.all_connections(): - connection.prepareGeometryChange() - - return super().itemChange(change, value) - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent): - if event.button() == Qt.LeftButton and self.__ui_interactor is None: - wgt = event.widget().parent() - assert isinstance(wgt, GraphicsSceneViewingWidgetBase) - pos = event.scenePos() - r2 = (self.__input_radius + 0.5*self.__line_width)**2 - - # check expand button - expand_button_shape = self._get_expandbutton_shape() - if expand_button_shape.contains(event.pos()): - self.set_expanded(not self.__expanded) - event.ignore() - return - - for input in self.input_names(): - inpos = self.get_input_position(input) - if QPointF.dotProduct(inpos - pos, inpos - pos) <= r2 and wgt.request_ui_focus(self): - snap_points = [y for x in self.__scene_container.nodes() if x != self for y in x.output_snap_points()] - displayer = NodeConnectionCreatePreview(None, self, '', input, snap_points, 15, self._ui_interactor_finished) - self.scene().addItem(displayer) - self.__ui_interactor = displayer - self.__ui_grabbed_conn = input - self.__ui_widget = wgt - event.accept() - self.__ui_interactor.mousePressEvent(event) - return - - for output in self.output_names(): - outpos = self.get_output_position(output) - if QPointF.dotProduct(outpos - pos, outpos - pos) <= r2 and wgt.request_ui_focus(self): - snap_points = [y for x in self.__scene_container.nodes() if x != self for y in x.input_snap_points()] - displayer = NodeConnectionCreatePreview(self, None, output, '', snap_points, 15, self._ui_interactor_finished) - self.scene().addItem(displayer) - self.__ui_interactor = displayer - self.__ui_grabbed_conn = output - self.__ui_widget = wgt - event.accept() - self.__ui_interactor.mousePressEvent(event) - return - - if not self._get_nodeshape().contains(event.pos()): - event.ignore() - return - - super().mousePressEvent(event) - self.__move_start_selection = {self} - self.__move_start_position = None - - # check for special picking: shift+move should move all upper connected nodes - if event.modifiers() & Qt.ShiftModifier or event.modifiers() & Qt.ControlModifier: - selecting_inputs = event.modifiers() & Qt.ShiftModifier - selecting_outputs = event.modifiers() & Qt.ControlModifier - extra_selected_nodes = set() - if selecting_inputs: - extra_selected_nodes.update(self.input_nodes()) - if selecting_outputs: - extra_selected_nodes.update(self.output_nodes()) - - extra_selected_nodes_ordered = list(extra_selected_nodes) - for relnode in extra_selected_nodes_ordered: - relnode.setSelected(True) - relrelnodes = set() - if selecting_inputs: - relrelnodes.update(node for node in relnode.input_nodes() if node not in extra_selected_nodes) - if selecting_outputs: - relrelnodes.update(node for node in relnode.output_nodes() if node not in extra_selected_nodes) - extra_selected_nodes_ordered.extend(relrelnodes) - extra_selected_nodes.update(relrelnodes) - self.setSelected(True) - for item in self.scene().selectedItems(): - if isinstance(item, Node): - self.__move_start_selection.add(item) - item.__move_start_position = None - - if event.button() == Qt.RightButton: - # context menu time - view = event.widget().parent() - assert isinstance(view, GraphicsSceneViewingWidgetBase) - view.item_requests_context_menu(self) - event.accept() - - def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent): - # if self.__ui_interactor is not None: - # event.accept() - # self.__ui_interactor.mouseMoveEvent(event) - # return - super().mouseMoveEvent(event) - - def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): - # if self.__ui_interactor is not None: - # event.accept() - # self.__ui_interactor.mouseReleaseEvent(event) - # return - super().mouseReleaseEvent(event) - if self.__move_start_position is not None: - if self.__scene_container.node_snapping_enabled(): - for node in self.__move_start_selection: - pos = node.pos() - snapx = node.base_width / 4 - snapy = node.base_height / 4 - node.setPos(round(pos.x() / snapx) * snapx, - round(pos.y() / snapy) * snapy) - self.scene()._nodes_were_moved([(node, node.__move_start_position) for node in self.__move_start_selection]) - for node in self.__move_start_selection: - node.__move_start_position = None - - def hoverMoveEvent(self, event): - self.__hoverover_pos = event.pos() - - def hoverLeaveEvent(self, event): - self.__hoverover_pos = None - self.update() - - @Slot(object) - def _ui_interactor_finished(self, snap_point: Optional[NodeConnSnapPoint]): - assert self.__ui_interactor is not None - call_later(lambda x: logger.debug(f'later removing {x}') or x.scene().removeItem(x), self.__ui_interactor) - if self.scene() is None: # if scheduler deleted us while interacting - return - if self.__ui_widget is None: - raise RuntimeError('interaction finalizer called, but ui widget is not set') - - grabbed_conn = self.__ui_grabbed_conn - self.__ui_widget.release_ui_focus(self) - self.__ui_widget = None - self.__ui_interactor = None - self.__ui_grabbed_conn = None - - # actual node reconection - if snap_point is None: - logger.debug('no change') - return - - setting_out = not snap_point.connection_is_input() - self.__data_controller.add_connection(snap_point.node().get_id() if setting_out else self.get_id(), - snap_point.connection_name() if setting_out else grabbed_conn, - snap_point.node().get_id() if not setting_out else self.get_id(), - snap_point.connection_name() if not setting_out else grabbed_conn) - - -class SceneNodeConnection(NodeConnection): - def __init__(self, scene: GraphicsSceneWithNodesAndTasks, id: int, nodeout: Node, nodein: Node, outname: str, inname: str, data_controller: SceneDataController): - super().__init__(scene, id, nodeout, nodein, outname, inname) - self.__scene_container = scene - self.__data_controller: SceneDataController = data_controller - self.setFlags(QGraphicsItem.ItemSendsGeometryChanges) # QGraphicsItem.ItemIsSelectable | - self.setAcceptHoverEvents(True) # for highlights - - self.setZValue(-1) - self.__line_width = 6 # TODO: rename it to match what it represents - self.__wire_pick_radius = 15 - self.__pick_radius2 = 100 ** 2 - self.__curv = 150 - self.__wire_highlight_radius = 5 - - self.__temporary_invalid = False - - self.__ui_interactor: Optional[NodeConnectionCreatePreview] = None - - self.__ui_last_pos = QPointF() - self.__ui_grabbed_beginning: bool = True - - self.__pen = QPen(QColor(64, 64, 64, 192)) - self.__pen.setWidthF(3) - self.__pen_highlight = QPen(QColor(92, 92, 92, 192)) - self.__pen_highlight.setWidthF(3) - self.__thick_pen = QPen(QColor(144, 144, 144, 128)) - self.__thick_pen.setWidthF(4) - self.__last_drawn_path: Optional[QPainterPath] = None - - self.__stroker = QPainterPathStroker() - self.__stroker.setWidth(2 * self.__wire_pick_radius) - - self.__hoverover_pos = None - - # to ensure correct interaction - self.__ui_widget: Optional[GraphicsSceneViewingWidgetBase] = None - - def distance_to_point(self, pos: QPointF): - """ - returns approx distance to a given point - currently it has the most crude implementation - :param pos: - :return: - """ - - line = self.get_painter_path() - # determine where to start - outnode, outname = self.output() - innode, inname = self.input() - p0 = outnode.get_output_position(outname) - p1 = innode.get_input_position(inname) - - if length2(p0-pos) < length2(p1-pos): # pos closer to p0 - curper = 0 - curstep = 0.1 - lastsqlen = length2(p0 - pos) - else: - curper = 1 - curstep = -0.1 - lastsqlen = length2(p1 - pos) - - sqlen = lastsqlen - while 0 <= curper <= 1: - curper += curstep - sqlen = length2(line.pointAtPercent(curper) - pos) - if sqlen > lastsqlen: - curstep *= -0.1 - if abs(sqlen - lastsqlen) < 0.001**2 or abs(curstep) < 1e-7: - break - lastsqlen = sqlen - - return sqrt(sqlen) - - def boundingRect(self) -> QRectF: - outnode, outname = self.output() - innode, inname = self.input() - if outname not in outnode.output_names() or inname not in innode.input_names(): - self.__temporary_invalid = True - return QRectF() - self.__temporary_invalid = False - hlw = self.__line_width - line = self.get_painter_path() - return line.boundingRect().adjusted(-hlw - self.__wire_pick_radius, -hlw, hlw + self.__wire_pick_radius, hlw) - - def shape(self): - # this one is mainly needed for proper selection and item picking - return self.__stroker.createStroke(self.get_painter_path()) - - def get_painter_path(self, close_path=False): - line = QPainterPath() - - outnode, outname = self.output() - innode, inname = self.input() - p0 = outnode.get_output_position(outname) - p1 = innode.get_input_position(inname) - curv = self.__curv - curv = min((p0-p1).manhattanLength()*0.5, curv) - line.moveTo(p0) - line.cubicTo(p0 + QPointF(0, curv), p1 - QPointF(0, curv), p1) - if close_path: - line.cubicTo(p1 - QPointF(0, curv), p0 + QPointF(0, curv), p0) - return line - - def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: - if self.__temporary_invalid: - return - if self.__ui_interactor is not None: # if interactor exists - it does all the drawing - return - line = self.get_painter_path() - - painter.setPen(self.__pen) - - if self.__hoverover_pos is not None: - hldiag = QPointF(self.__wire_highlight_radius, self.__wire_highlight_radius) - if line.intersects(QRectF(self.__hoverover_pos - hldiag, self.__hoverover_pos + hldiag)): - painter.setPen(self.__pen_highlight) - - if self.isSelected(): - painter.setPen(self.__thick_pen) - - painter.drawPath(line) - # painter.drawRect(self.boundingRect()) - self.__last_drawn_path = line - - def hoverMoveEvent(self, event): - self.__hoverover_pos = event.pos() - - def hoverLeaveEvent(self, event): - self.__hoverover_pos = None - self.update() - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent): - event.ignore() - if event.button() != Qt.LeftButton: - return - line = self.get_painter_path(close_path=True) - circle = QPainterPath() - circle.addEllipse(event.scenePos(), self.__wire_pick_radius, self.__wire_pick_radius) - if self.__ui_interactor is None and line.intersects(circle): - logger.debug('wire candidate for picking detected') - wgt = event.widget() - if wgt is None: - return - - p = event.scenePos() - outnode, outname = self.output() - innode, inname = self.input() - p0 = outnode.get_output_position(outname) - p1 = innode.get_input_position(inname) - d02 = QPointF.dotProduct(p0 - p, p0 - p) - d12 = QPointF.dotProduct(p1 - p, p1 - p) - if d02 > self.__pick_radius2 and d12 > self.__pick_radius2: # if picked too far from ends - just select - super().mousePressEvent(event) - event.accept() - return - - # this way we report to scene event handler that we are candidates for picking - if hasattr(event, 'wire_candidates'): - event.wire_candidates.append((self.distance_to_point(p), self)) - - def post_mousePressEvent(self, event: QGraphicsSceneMouseEvent): - """ - this will be called by scene as continuation of mousePressEvent - IF scene decides so. - :param event: - :return: - """ - wgt = event.widget().parent() - p = event.scenePos() - outnode, outname = self.output() - innode, inname = self.input() - p0 = outnode.get_output_position(outname) - p1 = innode.get_input_position(inname) - d02 = QPointF.dotProduct(p0 - p, p0 - p) - d12 = QPointF.dotProduct(p1 - p, p1 - p) - - assert isinstance(wgt, GraphicsSceneViewingWidgetBase) - if wgt.request_ui_focus(self): - event.accept() - - output_picked = d02 < d12 - if output_picked: - snap_points = [y for x in self.__scene_container.nodes() if x != innode for y in x.output_snap_points()] - else: - snap_points = [y for x in self.__scene_container.nodes() if x != outnode for y in x.input_snap_points()] - self.__ui_interactor = NodeConnectionCreatePreview(None if output_picked else outnode, - innode if output_picked else None, - outname, inname, - snap_points, 15, self._ui_interactor_finished, True) - self.update() - self.__ui_widget = wgt - self.scene().addItem(self.__ui_interactor) - self.__ui_interactor.mousePressEvent(event) - - def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: - # if self.__ui_interactor is not None: # redirect input, cuz scene will direct all events to this item. would be better to change focus, but so far scene.setFocusItem did not work as expected - # self.__ui_interactor.mouseMoveEvent(event) - # event.accept() - super().mouseMoveEvent(event) - - def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: - # event.ignore() - # if event.button() != Qt.LeftButton: - # return - # if self.__ui_interactor is not None: # redirect input, cuz scene will direct all events to this item. would be better to change focus, but so far scene.setFocusItem did not work as expected - # self.__ui_interactor.mouseReleaseEvent(event) - # event.accept() - # self.ungrabMouse() - logger.debug('ungrabbing mouse') - self.ungrabMouse() - super().mouseReleaseEvent(event) - - # _dbg_shitlist = [] - @Slot(object) - def _ui_interactor_finished(self, snap_point: Optional[NodeConnSnapPoint]): - assert self.__ui_interactor is not None - call_later(lambda x: logger.debug(f'later removing {x}') or x.scene().removeItem(x), self.__ui_interactor) - if self.scene() is None: # if scheduler deleted us while interacting - return - # NodeConnection._dbg_shitlist.append(self.__ui_interactor) - self.__ui_widget.release_ui_focus(self) - self.__ui_widget = None - is_cutting = self.__ui_interactor.is_cutting() - self.__ui_interactor = None - self.update() - - # are we cutting the wire - if is_cutting: - self.__data_controller.cut_connection_by_id(self.get_id()) - return - - # actual node reconection - if snap_point is None: - logger.debug('no change') - return - - changing_out = not snap_point.connection_is_input() - self.__data_controller.change_connection_by_id( - self.get_id(), - to_outnode_id=snap_point.node().get_id() if changing_out else None, - to_outname=snap_point.connection_name() if changing_out else None, - to_innode_id=None if changing_out else snap_point.node().get_id(), - to_inname=None if changing_out else snap_point.connection_name() - ) - # scene.request_node_connection_change(self.get_id(), - # snap_point.node().get_id() if changing_out else None, - # snap_point.connection_name() if changing_out else None, - # None if changing_out else snap_point.node().get_id(), - # None if changing_out else snap_point.connection_name()) - - -class SceneTask(Task): - __brushes = None - __borderpen = None - __paused_pen = None - - def __init__(self, scene: GraphicsSceneWithNodesAndTasks, task_data: TaskData, data_controller: SceneDataController): - super().__init__(scene, task_data) - self.__scene_container = scene - self.__data_controller = data_controller - self.setAcceptHoverEvents(True) - self.__hoverover_pos = None - # self.setFlags(QGraphicsItem.ItemIsSelectable) - self.setZValue(1) - self.__layer = 0 # draw layer from 0 - main up to inf. kinda like LOD with highres being 0 - self.__visible_layers_count = 2 - - self.__size = 16 - self.__line_width = 1.5 - - self.__ui_interactor = None - self.__press_pos = None - - self.__animation_group: Optional[QSequentialAnimationGroup] = None - self.__final_pos = None - self.__final_layer = None - - self.__mainshape_cache = None # NOTE: DYNAMIC SIZE OR LINE WIDTH ARE NOT SUPPORTED HERE! - self.__selshape_cache = None - self.__pausedshape_cache = None - self.__bound_cache = None - - self.__requested_invocs_while_selected = set() - - def lerpclr(c1, c2, t): - color = c1 - color.setAlphaF(lerp(color.alphaF(), c2.alphaF(), t)) - color.setRedF(lerp(color.redF(), c2.redF(), t)) - color.setGreenF(lerp(color.greenF(), c2.redF(), t)) - color.setBlueF(lerp(color.blueF(), c2.redF(), t)) - return color - - if self.__borderpen is None: - SceneTask.__borderpen = [QPen(QColor(96, 96, 96, 255), self.__line_width), - QPen(QColor(128, 128, 128, 255), self.__line_width), - QPen(QColor(192, 192, 192, 255), self.__line_width)] - - if self.__brushes is None: - # brushes and paused_pen are precalculated for several layers with different alphas, just not to calc them in paint - def lerp(a, b, t): - return a*(1.0-t) + b*t - - SceneTask.__brushes = { - TaskState.WAITING: QBrush(QColor(64, 64, 64, 192)), - TaskState.GENERATING: QBrush(QColor(32, 128, 128, 192)), - TaskState.READY: QBrush(QColor(32, 64, 32, 192)), - TaskState.INVOKING: QBrush(QColor(108, 108, 12, 192)), - TaskState.IN_PROGRESS: QBrush(QColor(128, 128, 32, 192)), - TaskState.POST_WAITING: QBrush(QColor(96, 96, 96, 192)), - TaskState.POST_GENERATING: QBrush(QColor(128, 32, 128, 192)), - TaskState.DONE: QBrush(QColor(32, 192, 32, 192)), - TaskState.ERROR: QBrush(QColor(192, 32, 32, 192)), - TaskState.SPAWNED: QBrush(QColor(32, 32, 32, 192)), - TaskState.DEAD: QBrush(QColor(16, 19, 22, 192)), - TaskState.SPLITTED: QBrush(QColor(64, 32, 64, 192)), - TaskState.WAITING_BLOCKED: QBrush(QColor(40, 40, 50, 192)), - TaskState.POST_WAITING_BLOCKED: QBrush(QColor(40, 40, 60, 192)) - } - for k, v in SceneTask.__brushes.items(): - ocolor = v.color() - SceneTask.__brushes[k] = [] - for i in range(self.__visible_layers_count): - color = lerpclr(ocolor, QColor.fromRgbF(0, 0, 0, 1), i*1.0/self.__visible_layers_count) - SceneTask.__brushes[k].append(QColor(color)) - if self.__paused_pen is None: - ocolor = QColor(64, 64, 128, 192) - SceneTask.__paused_pen = [] - for i in range(self.__visible_layers_count): - color = lerpclr(ocolor, QColor.fromRgbF(0, 0, 0, 1), i*1.0/self.__visible_layers_count) - SceneTask.__paused_pen.append(QPen(color, self.__line_width*3)) - - def layer_visible(self): - return self.__layer < self.__visible_layers_count - - def boundingRect(self) -> QRectF: - if self.__bound_cache is None: - lw = self.__line_width - self.__bound_cache = QRectF(QPointF(-0.5 * (self.__size + lw), -0.5 * (self.__size + lw)), - QSizeF(self.__size + lw, self.__size + lw)) - return self.__bound_cache - - def _get_mainpath(self) -> QPainterPath: - if self.__mainshape_cache is None: - path = QPainterPath() - path.addEllipse(-0.5 * self.__size, -0.5 * self.__size, - self.__size, self.__size) - self.__mainshape_cache = path - return self.__mainshape_cache - - def _get_selectshapepath(self) -> QPainterPath: - if self.__selshape_cache is None: - path = QPainterPath() - lw = self.__line_width - path.addEllipse(-0.5 * (self.__size + lw), -0.5 * (self.__size + lw), - self.__size + lw, self.__size + lw) - self.__selshape_cache = path - return self.__selshape_cache - - def _get_pausedpath(self) -> QPainterPath: - if self.__pausedshape_cache is None: - path = QPainterPath() - lw = self.__line_width - path.addEllipse(-0.5 * self.__size + 1.5*lw, -0.5 * self.__size + 1.5*lw, - self.__size - 3*lw, self.__size - 3*lw) - self.__pausedshape_cache = path - return self.__pausedshape_cache - - def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: - if self.__layer >= self.__visible_layers_count: - return - if self.node() is None: # probably temporary state due to asyncronous incoming events from scheduler - return # or we can draw them somehow else? - screen_rect = painter.worldTransform().mapRect(self.boundingRect()) - - path = self._get_mainpath() - brush = self.__brushes[self.state()][self.__layer] - painter.fillPath(path, brush) - if progress := self.get_progress(): - arcpath = QPainterPath() - arcpath.arcTo(QRectF(-0.5*self.__size, -0.5*self.__size, self.__size, self.__size), - 90, -3.6*progress) - arcpath.closeSubpath() - painter.fillPath(arcpath, self.__brushes[TaskState.DONE][self.__layer]) - if self.paused(): - painter.setPen(self.__paused_pen[self.__layer]) - painter.drawPath(self._get_pausedpath()) - - if screen_rect.width() > 7: - if self.isSelected(): - painter.setPen(self.__borderpen[2]) - elif self.__hoverover_pos is not None: - painter.setPen(self.__borderpen[1]) - else: - painter.setPen(self.__borderpen[0]) - painter.drawPath(path) - - def draw_size(self): - return self.__size - - def set_layer(self, layer: int): - assert layer >= 0 - self.__layer = layer - self.setZValue(1.0/(1.0 + layer)) - - def add_item_watcher(self, watcher: "NetworkItemWatcher"): - super().add_item_watcher(watcher) - # additionally refresh ui if we are not being watched - if len(self.item_watchers()) == 1: # it's a first watcher - self.refresh_ui() - - def set_name(self, name: str): - super().set_name(name) - self.refresh_ui() - - def set_groups(self, groups: Set[str]): - super().set_groups(groups) - self.refresh_ui() - - def refresh_ui(self): - """ - unlike update - this method actually queries new task ui status - if task is not selected or not watched- does nothing - :return: - """ - if not self.isSelected() and len(self.item_watchers()) == 0: - return - self.__data_controller.request_log_meta(self.get_id()) # update all task metadata: which nodes it ran on and invocation numbers only - self.__data_controller.request_attributes(self.get_id()) - - for invoc_id, nid, invoc_dict in self.invocation_logs(): - if invoc_dict is None: - continue - if (isinstance(invoc_dict, IncompleteInvocationLogData) - or invoc_dict.invocation_state != InvocationState.FINISHED) and invoc_id in self.__requested_invocs_while_selected: - self.__requested_invocs_while_selected.remove(invoc_id) - - def final_location(self) -> (Node, QPointF): - if self.__animation_group is not None: - assert self.__final_pos is not None - return self.node(), self.__final_pos - else: - return self.node(), self.pos() - - def final_scene_position(self) -> QPointF: - fnode, fpos = self.final_location() - if fnode is not None: - fpos = fnode.mapToScene(fpos) - return fpos - - def is_in_animation(self): - return self.__animation_group is not None - - @Slot() - def _clear_animation_group(self): - if self.__animation_group is not None: - ag, self.__animation_group = self.__animation_group, None - ag.stop() # just in case some recursion occures - ag.deleteLater() - self.setParentItem(self.node()) - self.setPos(self.__final_pos) - self.set_layer(self.__final_layer) - self.__final_pos = None - self.__final_layer = None - - def set_task_position(self, node: Node, pos: QPointF, layer: int): - """ - set task position to given node and give pos/layer inside that node - also cancels any active move animation - """ - if self.__animation_group is not None: - self.__animation_group.stop() - self.__animation_group.deleteLater() - self.__animation_group = None - - self.setParentItem(node) - if pos is not None: - self.setPos(pos) - if layer is not None: - self.set_layer(layer) - - def append_task_move_animation(self, node: Node, pos: QPointF, layer: int): - """ - set task position to given node and give pos/layer inside that node, - but do it with animation - """ - # first try to optimize, if we move on the same node to invisible layer - don't animate - if node == self.node() and layer >= self.__visible_layers_count and self.__animation_group is None: - return self.set_task_position(node, pos, layer) - - # - dist = ((pos if node is None else node.mapToScene(pos)) - self.final_scene_position()) - ldist = sqrt(QPointF.dotProduct(dist, dist)) - self.set_layer(0) - animgroup = self.__animation_group - if animgroup is None: - animgroup = QSequentialAnimationGroup(self.scene()) - animgroup.finished.connect(self._clear_animation_group) - anim_speed = max(1.0, animgroup.animationCount() - 2) # -2 to start speedup only after a couple anims in queue - start_node, start_pos = self.final_location() - new_animation = TaskAnimation(self, start_node, start_pos, node, pos, duration=max(1, int(ldist / anim_speed)), parent=animgroup) - if self.__animation_group is None: - self.setParentItem(None) - self.__animation_group = animgroup - - self.__final_pos = pos - self.__final_layer = layer - # turns out i do NOT need to add animation to group IF animgroup was passed as parent to animation - it's added automatically - # self.__animation_group.addAnimation(new_animation) - if self.__animation_group.state() != QAbstractAnimation.Running: - self.__animation_group.start() - - def itemChange(self, change, value): - if change == QGraphicsItem.ItemSelectedHasChanged: - if value and self.node() is not None: # item was just selected - self.refresh_ui() - elif not value: - self.setFlag(QGraphicsItem.ItemIsSelectable, False) # we are not selectable any more by band selection until directly clicked - pass - - elif change == QGraphicsItem.ItemSceneChange: - if value is None: # removing item from scene - if self.__animation_group is not None: - self.__animation_group.stop() - self.__animation_group.clear() - self.__animation_group.deleteLater() - self.__animation_group = None - if self.node() is not None: - self.node().remove_task(self) - return super().itemChange(change, value) # TODO: maybe move this to scene's remove item? - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: - if not self._get_selectshapepath().contains(event.pos()): - event.ignore() - return - self.setFlag(QGraphicsItem.ItemIsSelectable, True) # if we are clicked - we are now selectable until unselected. This is to avoid band selection - super().mousePressEvent(event) - self.__press_pos = event.scenePos() - - if event.button() == Qt.RightButton: - # context menu time - view = event.widget().parent() - assert isinstance(view, GraphicsSceneViewingWidgetBase) - view.item_requests_context_menu(self) - event.accept() - - def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: - if self.__ui_interactor is None: - movedist = event.scenePos() - self.__press_pos - if QPointF.dotProduct(movedist, movedist) > 2500: # TODO: config this rad squared - self.__ui_interactor = TaskPreview(self) - self.scene().addItem(self.__ui_interactor) - if self.__ui_interactor: - self.__ui_interactor.mouseMoveEvent(event) - else: - super().mouseMoveEvent(event) - - def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: - if self.__ui_interactor: - self.__ui_interactor.mouseReleaseEvent(event) - nodes = [x for x in self.scene().items(event.scenePos(), Qt.IntersectsItemBoundingRect) if isinstance(x, Node)] # TODO: dirty, implement such method in one of scene subclasses - if len(nodes) > 0: - logger.debug(f'moving item {self} to node {nodes[0]}') - self.__data_controller.request_set_task_node(self.get_id(), nodes[0].get_id()) - call_later(self.__ui_interactor.scene().removeItem, self.__ui_interactor) - self.__ui_interactor = None - - else: - super().mouseReleaseEvent(event) - - def hoverMoveEvent(self, event): - self.__hoverover_pos = event.pos() - - def hoverLeaveEvent(self, event): - self.__hoverover_pos = None - self.update() - - @staticmethod - def _draw_dict_table(attributes: dict, table_name: str): - imgui.columns(2, table_name) - imgui.separator() - imgui.text('name') - imgui.next_column() - imgui.text('value') - imgui.next_column() - imgui.separator() - for key, val in attributes.items(): - imgui.text(key) - imgui.next_column() - imgui.text(repr(val)) - imgui.next_column() - imgui.columns(1) - - # - # interface - def draw_imgui_elements(self, drawing_widget): - imgui.text(f'Task {self.get_id()} {self.name()}') - imgui.text(f'state: {self.state().name}') - imgui.text(f'groups: {", ".join(self.groups())}') - imgui.text(f'parent id: {self.parent_task_id()}') - imgui.text(f'children count: {self.children_tasks_count()}') - imgui.text(f'split level: {self.split_level()}') - imgui.text(f'invocation attempts: {self.latest_invocation_attempt()}') - - # first draw attributes - if self.attributes(): - self._draw_dict_table(self.attributes(), 'node_task_attributes') - - if env_res_args := self.environment_attributes(): - tab_expanded, _ = imgui.collapsing_header(f'environment resolver attributes##collapsing_node_task_environment_resolver_attributes') - if tab_expanded: - imgui.text(f'environment resolver: "{env_res_args.name()}"') - if env_res_args.arguments(): - self._draw_dict_table(env_res_args.arguments(), 'node_task_environment_resolver_attributes') - - # now draw log - imgui.text('Logs:') - for node_id, invocs in self.invocation_logs_mapping().items(): - node: Node = self.__scene_container.get_node(node_id) - if node is None: - logger.warning(f'node for task {self.get_id()} does not exist') - continue - node_name: str = node.node_name() - node_expanded, _ = imgui.collapsing_header(f'node {node_id}' + (f' "{node_name}"' if node_name else '')) - if not node_expanded: # or invocs is None: - continue - for invoc_id, invoc_log in invocs.items(): - # TODO: pyimgui is not covering a bunch of fancy functions... watch when it's done - imgui.indent(10) - invoc_expanded, _ = imgui.collapsing_header(f'invocation {invoc_id}' + - (f', worker {invoc_log.worker_id}' if isinstance(invoc_log, InvocationLogData) is not None else '') + - f', time: {timedelta(seconds=round(invoc_log.invocation_runtime)) if invoc_log.invocation_runtime is not None else "N/A"}' + - f'###logentry_{invoc_id}') - if not invoc_expanded: - imgui.unindent(10) - continue - if invoc_id not in self.__requested_invocs_while_selected: - self.__requested_invocs_while_selected.add(invoc_id) - self.__data_controller.request_log(invoc_id) - if isinstance(invoc_log, IncompleteInvocationLogData): - imgui.text('...fetching...') - else: - if invoc_log.stdout: - if imgui.button(f'open in viewer##{invoc_id}'): - fetch_and_open_log_viewer(self.scene(), invoc_id, drawing_widget, update_interval=None if invoc_log.invocation_state == InvocationState.FINISHED else 5) - - imgui.text_unformatted(invoc_log.stdout or '...nothing here...') - if invoc_log.invocation_state == InvocationState.IN_PROGRESS: - if imgui.button('update'): - logger.debug('clicked') - if invoc_id in self.__requested_invocs_while_selected: - self.__requested_invocs_while_selected.remove(invoc_id) - imgui.unindent(10) - - -class NodeConnectionCreatePreview(QGraphicsItem): - def __init__(self, nodeout: Optional[Node], nodein: Optional[Node], outname: str, inname: str, snap_points: List[NodeConnSnapPoint], snap_radius: float, report_done_here: Callable, do_cutting: bool = False): - super().__init__() - assert nodeout is None and nodein is not None or \ - nodeout is not None and nodein is None - self.setFlags(QGraphicsItem.ItemSendsGeometryChanges) - self.setZValue(10) - self.__nodeout = nodeout - self.__nodein = nodein - self.__outname = outname - self.__inname = inname - self.__snappoints = snap_points - self.__snap_radius2 = snap_radius * snap_radius - self.setZValue(-1) - self.__line_width = 4 - self.__curv = 150 - self.__breakdist2 = 200**2 - - self.__ui_last_pos = QPointF() - self.__finished_callback = report_done_here - - self.__pen = QPen(QColor(64, 64, 64, 192)) - self.__pen.setWidthF(3) - - self.__do_cutting = do_cutting - self.__cutpen = QPen(QColor(96, 32, 32, 192)) - self.__cutpen.setWidthF(3) - self.__cutpen.setStyle(Qt.DotLine) - - self.__is_snapping = False - - self.__orig_pos: Optional[QPointF] = None - - def get_painter_path(self): - if self.__nodein is not None: - p0 = self.__ui_last_pos - p1 = self.__nodein.get_input_position(self.__inname) - else: - p0 = self.__nodeout.get_output_position(self.__outname) - p1 = self.__ui_last_pos - - curv = self.__curv - curv = min((p0 - p1).manhattanLength() * 0.5, curv) - - line = QPainterPath() - line.moveTo(p0) - line.cubicTo(p0 + QPointF(0, curv), p1 - QPointF(0, curv), p1) - return line - - def boundingRect(self) -> QRectF: - hlw = self.__line_width - - if self.__nodein is not None: - inputpos = self.__ui_last_pos - outputpos = self.__nodein.get_input_position(self.__inname) - else: - inputpos = self.__nodeout.get_output_position(self.__outname) - outputpos = self.__ui_last_pos - - return QRectF(QPointF(min(inputpos.x(), outputpos.x()) - hlw, min(inputpos.y(), outputpos.y()) - hlw), - QPointF(max(inputpos.x(), outputpos.x()) + hlw, max(inputpos.y(), outputpos.y()) + hlw)) - - def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: - line = self.get_painter_path() - if self.is_cutting(): - painter.setPen(self.__cutpen) - else: - painter.setPen(self.__pen) - painter.drawPath(line) - # painter.drawRect(self.boundingRect()) - - def mousePressEvent(self, event: QGraphicsSceneMouseEvent): - if event.button() != Qt.LeftButton: - event.ignore() - return - self.grabMouse() - pos = event.scenePos() - closest_snap = self.get_closest_snappoint(pos) - self.__is_snapping = False - if closest_snap is not None: - pos = closest_snap.pos() - self.__is_snapping = True - self.prepareGeometryChange() - self.__ui_last_pos = pos - if self.__orig_pos is None: - self.__orig_pos = pos - event.accept() - - def mouseMoveEvent(self, event): - pos = event.scenePos() - closest_snap = self.get_closest_snappoint(pos) - self.__is_snapping = False - if closest_snap is not None: - pos = closest_snap.pos() - self.__is_snapping = True - self.prepareGeometryChange() - self.__ui_last_pos = pos - if self.__orig_pos is None: - self.__orig_pos = pos - event.accept() - - def is_cutting(self): - """ - wether or not interactor is it cutting the wire state - :return: - """ - return self.__do_cutting and not self.__is_snapping and self.__orig_pos is not None and length2(self.__orig_pos - self.__ui_last_pos) > self.__breakdist2 - - def get_closest_snappoint(self, pos: QPointF) -> Optional[NodeConnSnapPoint]: - snappoints = [x for x in self.__snappoints if length2(x.pos() - pos) < self.__snap_radius2] - - if len(snappoints) == 0: - return None - - return min(snappoints, key=lambda x: length2(x.pos() - pos)) - - def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): - if event.button() != Qt.LeftButton: - event.ignore() - return - if self.__finished_callback is not None: - self.__finished_callback(self.get_closest_snappoint(event.scenePos())) - event.accept() - self.ungrabMouse() - - -class TaskPreview(QGraphicsItem): - def __init__(self, task: Task): - super().__init__() - self.setZValue(10) - self.__size = 16 - self.__line_width = 1.5 - self.__finished_callback = None - self.setZValue(10) - - self.__borderpen = QPen(QColor(192, 192, 192, 255), self.__line_width) - self.__brush = QBrush(QColor(64, 64, 64, 128)) - - def boundingRect(self) -> QRectF: - lw = self.__line_width - return QRectF(QPointF(-0.5 * (self.__size + lw), -0.5 * (self.__size + lw)), - QSizeF(self.__size + lw, self.__size + lw)) - - def _get_mainpath(self) -> QPainterPath: - path = QPainterPath() - path.addEllipse(-0.5 * self.__size, -0.5 * self.__size, - self.__size, self.__size) - return path - - def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: - path = self._get_mainpath() - brush = self.__brush - painter.fillPath(path, brush) - painter.setPen(self.__borderpen) - painter.drawPath(path) - - def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: - self.setPos(event.scenePos()) - - def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): - if self.__finished_callback is not None: - self.__finished_callback(event.scenePos()) # not used for now not to overcomplicate - event.accept() diff --git a/src/lifeblood_viewer/graphics_items/graphics_items.py b/src/lifeblood_viewer/graphics_items/graphics_items.py index 516895b1..a8095b4b 100644 --- a/src/lifeblood_viewer/graphics_items/graphics_items.py +++ b/src/lifeblood_viewer/graphics_items/graphics_items.py @@ -3,8 +3,8 @@ from enum import Enum from types import MappingProxyType -from .network_item import NetworkItemWithUI, NetworkItem from .network_item_watchers import NetworkItemWatcher, WatchableNetworkItem, WatchableNetworkItemProxy +from .scene_network_item import SceneNetworkItem, SceneNetworkItemWithUI from ..graphics_scene_base import GraphicsSceneBase from lifeblood.uidata import NodeUi @@ -22,36 +22,6 @@ logger = logging.get_logger('viewer') -class SceneNetworkItem(NetworkItem): - def __init__(self, scene: GraphicsSceneBase, id: int): - super().__init__(id) - self.__scene = scene - - def graphics_scene(self) -> GraphicsSceneBase: - return self.__scene - - def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value): - if change == QGraphicsItem.ItemSceneChange: # just before scene change - if self.scene() is not None and value is not None: - raise RuntimeError('changing scenes is not supported') - return super().itemChange(change, value) - - -class SceneNetworkItemWithUI(NetworkItemWithUI): - def __init__(self, scene: GraphicsSceneBase, id: int): - super().__init__(id) - self.__scene = scene - - def graphics_scene(self) -> GraphicsSceneBase: - return self.__scene - - def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value): - if change == QGraphicsItem.ItemSceneChange: # just before scene change - if self.scene() is not None and value is not None: - raise RuntimeError('changing scenes is not supported') - return super().itemChange(change, value) - - class Node(SceneNetworkItemWithUI, WatchableNetworkItemProxy): class TaskSortOrder(Enum): ID = 0 @@ -295,7 +265,7 @@ def itemChange(self, change, value): connection.scene().removeItem(connection) assert len(self.__connections) == 0 - return super(Node, self).itemChange(change, value) + return super().itemChange(change, value) class NodeConnection(SceneNetworkItem): diff --git a/src/lifeblood_viewer/graphics_items/network_item.py b/src/lifeblood_viewer/graphics_items/network_item.py index 0910c2b3..9a870284 100644 --- a/src/lifeblood_viewer/graphics_items/network_item.py +++ b/src/lifeblood_viewer/graphics_items/network_item.py @@ -1,16 +1,7 @@ -from PySide2.QtWidgets import QGraphicsItem - -class NetworkItem(QGraphicsItem): +class NetworkItem: def __init__(self, id): super().__init__() - - # cheat cuz Shiboken.Object does not respect mro - mro = self.__class__.mro() - cur_mro_i = mro.index(NetworkItem) - if len(mro) > cur_mro_i + 2: - super(mro[cur_mro_i+2], self).__init__() - self.__id = id def get_id(self): diff --git a/src/lifeblood_viewer/graphics_items/node_connection_snap_point.py b/src/lifeblood_viewer/graphics_items/node_connection_snap_point.py new file mode 100644 index 00000000..4dd4d582 --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/node_connection_snap_point.py @@ -0,0 +1,29 @@ +from .graphics_items import Node +from PySide2.QtCore import QPointF + + +class SnapPoint: + def pos(self) -> QPointF: + raise NotImplementedError() + + +class NodeConnSnapPoint(SnapPoint): + def __init__(self, node: Node, connection_name: str, connection_is_input: bool): + super().__init__() + self.__node = node + self.__conn_name = connection_name + self.__isinput = connection_is_input + + def node(self) -> Node: + return self.__node + + def connection_name(self) -> str: + return self.__conn_name + + def connection_is_input(self) -> bool: + return self.__isinput + + def pos(self) -> QPointF: + if self.__isinput: + return self.__node.get_input_position(self.__conn_name) + return self.__node.get_output_position(self.__conn_name) diff --git a/src/lifeblood_viewer/graphics_items/node_extra_items.py b/src/lifeblood_viewer/graphics_items/node_extra_items.py deleted file mode 100644 index 3f756436..00000000 --- a/src/lifeblood_viewer/graphics_items/node_extra_items.py +++ /dev/null @@ -1,44 +0,0 @@ -from PySide2.QtWidgets import QWidget, QGraphicsItem, QStyleOptionGraphicsItem -from PySide2.QtCore import Qt, Slot, QRectF, QPointF -from PySide2.QtGui import QPainterPath, QPainter, QBrush, QPen, QColor -from typing import Optional - - -class ImplicitSplitVisualizer(QGraphicsItem): - _arc_path: QPainterPath = None - _text_pen = None - - def __init__(self, parent_node): - super(ImplicitSplitVisualizer, self).__init__(parent_node) - - self.__brush = QBrush(QColor.fromRgbF(0.9, 0.6, 0.2, 0.2)) - - if self._arc_path is None: - newpath = QPainterPath() - arcbb = QRectF(-40, -30, 80, 60) - newpath.arcMoveTo(arcbb, 225) - newpath.arcTo(arcbb, 225, 90) - arcbb1 = QRectF(arcbb) - arcbb1.setTopLeft(arcbb.topLeft() * 0.8) - arcbb1.setBottomRight(arcbb.bottomRight() * 0.8) - newpath.arcTo(arcbb1, 315, -90) - newpath.closeSubpath() - - ImplicitSplitVisualizer._arc_path = newpath - ImplicitSplitVisualizer._text_pen = QPen(QColor.fromRgbF(1, 1, 1, 0.2)) - - self.__parent_node = parent_node - - def boundingRect(self) -> QRectF: - rect: QRectF = self.__parent_node.boundingRect() - arcrect = self._arc_path.boundingRect() - return QRectF(rect.left() + arcrect.left(), arcrect.top(), rect.width() + arcrect.width(), arcrect.height()).adjusted(-1, -1, 1, 1) # why adjusted? just not to forget later to adjust when pen gets involved. currently it's not needed - - def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: - for name in self.__parent_node.output_names(): - if len(self.__parent_node.output_connections(name)) > 1: - # TODO: review this - shift = QPointF(self.__parent_node.mapFromScene(self.__parent_node.get_output_position(name)).x(), 0) - painter.fillPath(self._arc_path.translated(shift), self.__brush) - painter.setPen(self._text_pen) - painter.drawText(QRectF(-20, 2, 40, 16).translated(shift), Qt.AlignHCenter | Qt.AlignTop, 'split') diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/__init__.py b/src/lifeblood_viewer/graphics_items/pretty_items/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/decorated_node.py b/src/lifeblood_viewer/graphics_items/pretty_items/decorated_node.py new file mode 100644 index 00000000..f5a9ac9e --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/pretty_items/decorated_node.py @@ -0,0 +1,21 @@ +from .node_decorator_base import NodeDecorator, NodeDecoratorFactoryBase +from .drawable_node import DrawableNode + +from lifeblood_viewer.graphics_scene_base import GraphicsSceneBase + +from typing import Iterable, List, Optional, Tuple + + +class DecoratedNode(DrawableNode): + def __init__(self, scene: GraphicsSceneBase, id: int, type: str, name: str, node_decorator_factories: Iterable[NodeDecoratorFactoryBase] = ()): + super().__init__(scene, id, type, name) + + # decorators + self.__decorators: List[NodeDecorator] = [fac.make_decorator(self) for fac in node_decorator_factories] + for decorator in self.__decorators: + decorator.setParentItem(self) + + def item_updated(self, *, redraw: bool = False, ui: bool = False): + super().item_updated(redraw=redraw, ui=ui) + for decorator in self.__decorators: + decorator.node_updated() diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/drawable_node.py b/src/lifeblood_viewer/graphics_items/pretty_items/drawable_node.py new file mode 100644 index 00000000..365549d8 --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/pretty_items/drawable_node.py @@ -0,0 +1,432 @@ +from lifeblood import logging +from lifeblood.enums import TaskState +from ..graphics_items import Node, Task +from ..node_connection_snap_point import NodeConnSnapPoint +from .drawable_task import DrawableTask + +from lifeblood_viewer.graphics_scene_base import GraphicsSceneBase + +from PySide2.QtCore import Qt, QPointF, QRectF +from PySide2.QtGui import QBrush, QColor, QLinearGradient, QPainter, QPainterPath, QPen +from PySide2.QtWidgets import QStyleOptionGraphicsItem, QWidget + +from typing import Iterable, List, Optional, Tuple + + +logger = logging.get_logger('viewer') + + +class DrawableNode(Node): + base_height = 100 + base_width = 150 + + def __init__(self, scene: GraphicsSceneBase, id: int, type: str, name: str): + super().__init__(scene, id, type, name) + + self.__visual_tasks: List[Task] = [] + + # display + self.__hoverover_pos: Optional[QPointF] = None + self.__height = self.base_height + self.__width = self.base_width + self.__pivot_x = 0 + self.__pivot_y = 0 + + self.__input_radius = 12 + self.__input_visible_radius = 8 + self.__line_width = 1 + + # prepare default drawing tools + self.__borderpen = QPen(QColor(96, 96, 96, 255)) + self.__borderpen_selected = QPen(QColor(144, 144, 144, 255)) + self.__caption_pen = QPen(QColor(192, 192, 192, 255)) + self.__typename_pen = QPen(QColor(128, 128, 128, 192)) + self.__borderpen.setWidthF(self.__line_width) + self.__header_brush = QBrush(QColor(48, 64, 48, 192)) + self.__body_brush = QBrush(QColor(48, 48, 48, 128)) + self.__connector_brush = QBrush(QColor(48, 48, 48, 192)) + self.__connector_brush_hovered = QBrush(QColor(96, 96, 96, 128)) + + self.__expanded = False + + self.__cached_bounds = None + self.__cached_nodeshape = None + self.__cached_bodymask = None + self.__cached_headershape = None + self.__cached_bodyshape = None + self.__cached_expandbutton_shape = None + + # query + + def __pos_transform_maybe(self, pos: QPointF, local: bool): + if local: + return pos + else: + return self.mapToScene(pos) + + def body_bottom_center(self, local=True) -> QPointF: + """ + return position of bottom center of the body of the node + """ + return self.__pos_transform_maybe( + QPointF(-self.__pivot_x, 0.5 * self.__height - self.__pivot_y), + local + ) + + def _input_radius(self) -> float: + return self.__input_radius + + def _line_width(self) -> float: + return self.__line_width + + # + + def is_expanded(self) -> bool: + return self.__expanded + + def set_expanded(self, expanded: bool): + if self.__expanded == expanded: + return + self.__expanded = expanded + self.prepareGeometryChange() + self.__height = self.base_height + if expanded: + self.__height += 225 + self.__pivot_y -= 225/2 + # self.setPos(self.pos() + QPointF(0, 225*0.5)) + else: + self.__pivot_y = 0 + # self.setPos(self.pos() - QPointF(0, 225 * 0.5)) # TODO: modify painterpath getters to avoid moving nodes on expand + + for i, task in enumerate(self.tasks()): + self.__make_task_child_with_position(task, *self.get_task_pos(task, i), animate=True) + self.item_updated(redraw=True) + + def get_input_position(self, name: str = 'main', *, local: bool = False) -> QPointF: + if not self.input_names(): + idx = 0 + cnt = 1 + elif name not in self.input_names(): + raise RuntimeError(f'unexpected input name {name}') + else: + idx = self.input_names().index(name) + cnt = len(self.input_names()) + assert cnt > 0 + return self.__pos_transform_maybe( + QPointF( + -0.5 * self.__width + (idx + 1) * self.__width/(cnt + 1) - self.__pivot_x, + -0.5 * self.__height - self.__pivot_y + ), + local + ) + + def get_output_position(self, name: str = 'main', *, local: bool = False) -> QPointF: + if not self.output_names(): + idx = 0 + cnt = 1 + elif name not in self.output_names(): + raise RuntimeError(f'unexpected output name {name} , {self.output_names()}') + else: + idx = self.output_names().index(name) + cnt = len(self.output_names()) + assert cnt > 0 + return self.__pos_transform_maybe( + QPointF( + -0.5 * self.__width + (idx + 1) * self.__width/(cnt + 1) - self.__pivot_x, + 0.5 * self.__height - self.__pivot_y + ), + local + ) + + def input_snap_points(self) -> List[NodeConnSnapPoint]: + # TODO: cache snap points, don't recalc them every time + if self.get_nodeui() is None: + return [] + inputs = [] + for input_name in self.get_nodeui().inputs_names(): + inputs.append(NodeConnSnapPoint(self, input_name, True)) + return inputs + + def output_snap_points(self) -> List[NodeConnSnapPoint]: + # TODO: cache snap points, don't recalc them every time + if self.get_nodeui() is None: + return [] + outputs = [] + for output_name in self.get_nodeui().outputs_names(): + outputs.append(NodeConnSnapPoint(self, output_name, False)) + return outputs + + # move animation + + def get_task_pos(self, task: "Task", pos_id: int) -> Tuple[QPointF, int]: + rect = self._get_bodyshape().boundingRect() + x, y = rect.topLeft().toTuple() + w, h = rect.size().toTuple() + d = task.draw_size() # TODO: this assumes size is same, so dont make it an instance method + r = d * 0.5 + + #w *= 0.5 + x += r + y += r + h -= d + w -= d + x += (d * pos_id % w) + y_shift = d * int(d * pos_id / w) + y += (y_shift % h) + return QPointF(x, y), int(y_shift / h) + + def __make_task_child_with_position(self, task: DrawableTask, pos: QPointF, layer: int, *, animate: bool = False): + """ + helper function that actually changes parent of a task and initializes animations if needed + """ + if animate: + task.append_task_move_animation(self, pos, layer) + else: + task.set_task_position(self, pos, layer) + + def add_task(self, task: Task): + if not isinstance(task, DrawableTask): + return super().add_task(task) + + # TODO: can anything be done to avoid this need for sorta dynamic_cast ? + assert isinstance(task, DrawableTask) + if task in self.__visual_tasks: + assert task in self.tasks() + return + + # the animated part + pos_id = len(self.__visual_tasks) + if task.node() is None: + self.__make_task_child_with_position(task, *self.get_task_pos(task, pos_id)) + else: + self.__make_task_child_with_position(task, *self.get_task_pos(task, pos_id), animate=True) + + super().add_task(task) + insert_at = self._find_insert_index_for_task(task, prefer_back=True) + + self.__visual_tasks.append(None) # temporary placeholder, it'll be eliminated either in the loop, or after if task is last + for i in reversed(range(insert_at + 1, len(self.__visual_tasks))): + self.__visual_tasks[i] = self.__visual_tasks[i - 1] # TODO: animated param should affect below! + self.__make_task_child_with_position(self.__visual_tasks[i], *self.get_task_pos(self.__visual_tasks[i], i), animate=True) + self.__visual_tasks[insert_at] = task + self.__make_task_child_with_position(self.__visual_tasks[insert_at], *self.get_task_pos(task, insert_at), animate=True) + + def remove_tasks(self, tasks_to_remove: Iterable["Task"]): + tasks_to_remove = set(tasks_to_remove) + super().remove_tasks(tasks_to_remove) + + self.__visual_tasks: List["Task"] = [None if x in tasks_to_remove else x for x in self.__visual_tasks] + off = 0 + for i, task in enumerate(self.__visual_tasks): + if task is None: + off += 1 + else: + self.__visual_tasks[i - off] = self.__visual_tasks[i] + self.__make_task_child_with_position(self.__visual_tasks[i - off], *self.get_task_pos(self.__visual_tasks[i - off], i - off), animate=True) + self.__visual_tasks = self.__visual_tasks[:-off] + for x in tasks_to_remove: + assert x not in self.__visual_tasks + + def remove_task(self, task_to_remove: "Task"): + super().remove_task(task_to_remove) + task_pid = self.__visual_tasks.index(task_to_remove) + + for i in range(task_pid, len(self.__visual_tasks) - 1): + self.__visual_tasks[i] = self.__visual_tasks[i + 1] + self.__make_task_child_with_position(self.__visual_tasks[i], *self.get_task_pos(self.__visual_tasks[i], i), animate=True) + self.__visual_tasks = self.__visual_tasks[:-1] + assert task_to_remove not in self.__visual_tasks + self.item_updated(redraw=True, ui=False) # cuz node displays task number - we should redraw + + def _find_insert_index_for_task(self, task, prefer_back=False): + if task.state() == TaskState.IN_PROGRESS and not prefer_back: + return 0 + + if task.state() != TaskState.IN_PROGRESS and prefer_back: + return len(self.__visual_tasks) + + # now fun thing: we either have IN_PROGRESS and prefer_back, or NOT IN_PROGRESS and NOT prefer_back + # and both cases have the same logic for position finding + for i, task in enumerate(self.__visual_tasks): + if task.state() != TaskState.IN_PROGRESS: + return i + else: + return len(self.__visual_tasks) + + def task_state_changed(self, task): + """ + here node might decide to highlight the task that changed state one way or another + """ + if task.state() not in (TaskState.IN_PROGRESS, TaskState.GENERATING, TaskState.POST_GENERATING): + return + + # find a place + append_at = self._find_insert_index_for_task(task) + + if append_at == len(self.__visual_tasks): # this is impossible case (in current impl of _find_insert_index_for_task) (cuz task is in __visual_tasks, and it's not in IN_PROGRESS) + return + + idx = self.__visual_tasks.index(task) + if idx <= append_at: # already in place (and ignore moving further + return + + # place where it has to be + for i in reversed(range(append_at + 1, idx+1)): + self.__visual_tasks[i] = self.__visual_tasks[i-1] + self.__make_task_child_with_position(self.__visual_tasks[i], *self.get_task_pos(self.__visual_tasks[i], i), animate=True) + self.__visual_tasks[append_at] = task + self.__make_task_child_with_position(self.__visual_tasks[append_at], *self.get_task_pos(task, append_at), animate=True) + + # + # scene item + # + + def boundingRect(self) -> QRectF: + if self.__cached_bounds is None: + lw = self.__width + self.__line_width + lh = self.__height + self.__line_width + self.__cached_bounds = QRectF( + -0.5 * lw - self.__pivot_x, + -0.5 * lh - (max(self.__input_radius, self.__input_visible_radius) + 0.5 * self.__line_width) - self.__pivot_y, + lw, + lh + 2 * (max(self.__input_radius, self.__input_visible_radius) + 0.5 * self.__line_width)) + return self.__cached_bounds + + def _get_nodeshape(self): + if self.__cached_nodeshape is None: + lw = self.__width + self.__line_width + lh = self.__height + self.__line_width + nodeshape = QPainterPath() + nodeshape.addRoundedRect(QRectF(-0.5 * lw - self.__pivot_x, -0.5 * lh - self.__pivot_y, lw, lh), 5, 5) + self.__cached_nodeshape = nodeshape + return self.__cached_nodeshape + + def _get_bodymask(self): + if self.__cached_bodymask is None: + lw = self.__width + self.__line_width + lh = self.__height + self.__line_width + bodymask = QPainterPath() + bodymask.addRect(-0.5 * lw - self.__pivot_x, -0.5 * lh + 32 - self.__pivot_y, lw, lh - 32) + self.__cached_bodymask = bodymask + return self.__cached_bodymask + + def _get_headershape(self): + if self.__cached_headershape is None: + self.__cached_headershape = self._get_nodeshape() - self._get_bodymask() + return self.__cached_headershape + + def _get_bodyshape(self): + if self.__cached_bodyshape is None: + self.__cached_bodyshape = self._get_nodeshape() & self._get_bodymask() + return self.__cached_bodyshape + + def _get_expandbutton_shape(self): + if self.__cached_expandbutton_shape is None: + bodyshape = self._get_bodyshape() + mask = QPainterPath() + body_bound = bodyshape.boundingRect() + corner = body_bound.bottomRight() + QPointF(15, 15) + top = corner + QPointF(0, -60) + left = corner + QPointF(-60, 0) + mask.moveTo(corner) + mask.lineTo(top) + mask.lineTo(left) + mask.lineTo(corner) + self.__cached_expandbutton_shape = bodyshape & mask + return self.__cached_expandbutton_shape + + def reanalyze_nodeui(self): + self.prepareGeometryChange() # not calling this seem to be able to break scene's internal index info on our connections + # bug that appears - on first scene load deleting a node with more than 1 input/output leads to crash + # on open nodes have 1 output, then they receive interface update and this func is called, and here's where bug may happen + + super().reanalyze_nodeui() + css = self.get_nodeui().color_scheme() + if css.secondary_color() is not None: + gradient = QLinearGradient(-self.__width * 0.1, 0, self.__width * 0.1, 16) + gradient.setColorAt(0.0, QColor(*(x * 255 for x in css.main_color()), 192)) + gradient.setColorAt(1.0, QColor(*(x * 255 for x in css.secondary_color()), 192)) + self.__header_brush = QBrush(gradient) + else: + self.__header_brush = QBrush(QColor(*(x * 255 for x in css.main_color()), 192)) + self.item_updated(redraw=True, ui=True) # cuz input count affects visualization in the graph + + def prepareGeometryChange(self): + super().prepareGeometryChange() + self.__cached_bounds = None + self.__cached_nodeshape = None + self.__cached_bodymask = None + self.__cached_headershape = None + self.__cached_bodyshape = None + self.__cached_expandbutton_shape = None + for conn in self.all_connections(): + conn.prepareGeometryChange() + + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: + screen_rect = painter.worldTransform().mapRect(self.boundingRect()) + painter.pen().setWidthF(self.__line_width) + nodeshape = self._get_nodeshape() + + if screen_rect.width() > 40: + ninputs = len(self.input_names()) + noutputs = len(self.output_names()) + r2 = (self.__input_radius + 0.5 * self.__line_width) ** 2 + for fi in range(ninputs + noutputs): + path = QPainterPath() + is_inputs = fi < ninputs + i = fi if is_inputs else fi - ninputs + input_point = QPointF(-0.5 * self.__width + (i + 1) * self.__width / ((ninputs if is_inputs else noutputs) + 1) - self.__pivot_x, + (-0.5 if is_inputs else 0.5) * self.__height - self.__pivot_y) + path.addEllipse(input_point, + self.__input_visible_radius, self.__input_visible_radius) + path -= nodeshape + pen = self.__borderpen + brush = self.__connector_brush + if self.__hoverover_pos is not None: + if QPointF.dotProduct(input_point - self.__hoverover_pos, input_point - self.__hoverover_pos) <= r2: + pen = self.__borderpen_selected + brush = self.__connector_brush_hovered + painter.setPen(pen) + painter.fillPath(path, brush) + painter.drawPath(path) + + headershape = self._get_headershape() + bodyshape = self._get_bodyshape() + + if self.isSelected(): + if screen_rect.width() > 100: + width_mult = 1 + elif screen_rect.width() > 50: + width_mult = 4 + elif screen_rect.width() > 25: + width_mult = 8 + else: + width_mult = 16 + self.__borderpen_selected.setWidth(self.__line_width * width_mult) + painter.setPen(self.__borderpen_selected) + else: + painter.setPen(self.__borderpen) + painter.fillPath(headershape, self.__header_brush) + painter.fillPath(bodyshape, self.__body_brush) + expand_button_shape = self._get_expandbutton_shape() + painter.fillPath(expand_button_shape, self.__header_brush) + painter.drawPath(nodeshape) + # draw highlighted elements on top + if self.__hoverover_pos and expand_button_shape.contains(self.__hoverover_pos): + painter.setPen(self.__borderpen_selected) + painter.drawPath(expand_button_shape) + + # draw header/text last + if screen_rect.width() > 50: + painter.setPen(self.__caption_pen) + painter.drawText(headershape.boundingRect(), Qt.AlignHCenter | Qt.AlignTop, self.node_name()) + painter.setPen(self.__typename_pen) + painter.drawText(headershape.boundingRect(), Qt.AlignRight | Qt.AlignBottom, self.node_type()) + painter.drawText(headershape.boundingRect(), Qt.AlignLeft | Qt.AlignBottom, f'{len(self.tasks())}') + + def hoverMoveEvent(self, event): + self.__hoverover_pos = event.pos() + + def hoverLeaveEvent(self, event): + self.__hoverover_pos = None + self.update() diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/drawable_task.py b/src/lifeblood_viewer/graphics_items/pretty_items/drawable_task.py new file mode 100644 index 00000000..5efb3efe --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/pretty_items/drawable_task.py @@ -0,0 +1,258 @@ +from math import sqrt +from lifeblood import logging +from lifeblood.enums import TaskState +from lifeblood.ui_protocol_data import TaskData +from .task_animation import TaskAnimation +from ..graphics_items import Node, Task + +from lifeblood_viewer.graphics_scene_container import GraphicsSceneWithNodesAndTasks + +from PySide2.QtCore import QAbstractAnimation, Slot, QPointF, QRectF, QSizeF, QSequentialAnimationGroup +from PySide2.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen +from PySide2.QtWidgets import QGraphicsItem, QStyleOptionGraphicsItem, QWidget + +from typing import Optional + + +logger = logging.get_logger('viewer') + + +class DrawableTask(Task): + __brushes = None + __borderpen = None + __paused_pen = None + + def __init__(self, scene: GraphicsSceneWithNodesAndTasks, task_data: TaskData): + super().__init__(scene, task_data) + self.setZValue(1) + self.__layer = 0 # draw layer from 0 - main up to inf. kinda like LOD with highres being 0 + self.__visible_layers_count = 2 + + self.__size = 16 + self.__line_width = 1.5 + + self.__animation_group: Optional[QSequentialAnimationGroup] = None + self.__final_pos = None + self.__final_layer = None + self.__hoverover_pos = None + + self.__mainshape_cache = None # NOTE: DYNAMIC SIZE OR LINE WIDTH ARE NOT SUPPORTED HERE! + self.__selshape_cache = None + self.__pausedshape_cache = None + self.__bound_cache = None + + def lerpclr(c1, c2, t): + color = c1 + color.setAlphaF(lerp(color.alphaF(), c2.alphaF(), t)) + color.setRedF(lerp(color.redF(), c2.redF(), t)) + color.setGreenF(lerp(color.greenF(), c2.redF(), t)) + color.setBlueF(lerp(color.blueF(), c2.redF(), t)) + return color + + if self.__borderpen is None: + DrawableTask.__borderpen = [ + QPen(QColor(96, 96, 96, 255), self.__line_width), + QPen(QColor(128, 128, 128, 255), self.__line_width), + QPen(QColor(192, 192, 192, 255), self.__line_width) + ] + + if self.__brushes is None: + # brushes and paused_pen are precalculated for several layers with different alphas, just not to calc them in paint + def lerp(a, b, t): + return a*(1.0-t) + b*t + + DrawableTask.__brushes = { + TaskState.WAITING: QBrush(QColor(64, 64, 64, 192)), + TaskState.GENERATING: QBrush(QColor(32, 128, 128, 192)), + TaskState.READY: QBrush(QColor(32, 64, 32, 192)), + TaskState.INVOKING: QBrush(QColor(108, 108, 12, 192)), + TaskState.IN_PROGRESS: QBrush(QColor(128, 128, 32, 192)), + TaskState.POST_WAITING: QBrush(QColor(96, 96, 96, 192)), + TaskState.POST_GENERATING: QBrush(QColor(128, 32, 128, 192)), + TaskState.DONE: QBrush(QColor(32, 192, 32, 192)), + TaskState.ERROR: QBrush(QColor(192, 32, 32, 192)), + TaskState.SPAWNED: QBrush(QColor(32, 32, 32, 192)), + TaskState.DEAD: QBrush(QColor(16, 19, 22, 192)), + TaskState.SPLITTED: QBrush(QColor(64, 32, 64, 192)), + TaskState.WAITING_BLOCKED: QBrush(QColor(40, 40, 50, 192)), + TaskState.POST_WAITING_BLOCKED: QBrush(QColor(40, 40, 60, 192)) + } + for k, v in DrawableTask.__brushes.items(): + ocolor = v.color() + DrawableTask.__brushes[k] = [] + for i in range(self.__visible_layers_count): + color = lerpclr(ocolor, QColor.fromRgbF(0, 0, 0, 1), i*1.0/self.__visible_layers_count) + DrawableTask.__brushes[k].append(QColor(color)) + if self.__paused_pen is None: + ocolor = QColor(64, 64, 128, 192) + DrawableTask.__paused_pen = [] + for i in range(self.__visible_layers_count): + color = lerpclr(ocolor, QColor.fromRgbF(0, 0, 0, 1), i*1.0/self.__visible_layers_count) + DrawableTask.__paused_pen.append(QPen(color, self.__line_width*3)) + + def layer_visible(self): + return self.__layer < self.__visible_layers_count + + def boundingRect(self) -> QRectF: + if self.__bound_cache is None: + lw = self.__line_width + self.__bound_cache = QRectF(QPointF(-0.5 * (self.__size + lw), -0.5 * (self.__size + lw)), + QSizeF(self.__size + lw, self.__size + lw)) + return self.__bound_cache + + def _get_mainpath(self) -> QPainterPath: + if self.__mainshape_cache is None: + path = QPainterPath() + path.addEllipse(-0.5 * self.__size, -0.5 * self.__size, + self.__size, self.__size) + self.__mainshape_cache = path + return self.__mainshape_cache + + def _get_selectshapepath(self) -> QPainterPath: + if self.__selshape_cache is None: + path = QPainterPath() + lw = self.__line_width + path.addEllipse(-0.5 * (self.__size + lw), -0.5 * (self.__size + lw), + self.__size + lw, self.__size + lw) + self.__selshape_cache = path + return self.__selshape_cache + + def _get_pausedpath(self) -> QPainterPath: + if self.__pausedshape_cache is None: + path = QPainterPath() + lw = self.__line_width + path.addEllipse(-0.5 * self.__size + 1.5*lw, -0.5 * self.__size + 1.5*lw, + self.__size - 3*lw, self.__size - 3*lw) + self.__pausedshape_cache = path + return self.__pausedshape_cache + + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: + if self.__layer >= self.__visible_layers_count: + return + if self.node() is None: # probably temporary state due to asyncronous incoming events from scheduler + return # or we can draw them somehow else? + screen_rect = painter.worldTransform().mapRect(self.boundingRect()) + + path = self._get_mainpath() + brush = self.__brushes[self.state()][self.__layer] + painter.fillPath(path, brush) + if progress := self.get_progress(): + arcpath = QPainterPath() + arcpath.arcTo(QRectF(-0.5*self.__size, -0.5*self.__size, self.__size, self.__size), + 90, -3.6*progress) + arcpath.closeSubpath() + painter.fillPath(arcpath, self.__brushes[TaskState.DONE][self.__layer]) + if self.paused(): + painter.setPen(self.__paused_pen[self.__layer]) + painter.drawPath(self._get_pausedpath()) + + if screen_rect.width() > 7: + if self.isSelected(): + painter.setPen(self.__borderpen[2]) + elif self.__hoverover_pos is not None: + painter.setPen(self.__borderpen[1]) + else: + painter.setPen(self.__borderpen[0]) + painter.drawPath(path) + + def draw_size(self): + return self.__size + + def set_layer(self, layer: int): + assert layer >= 0 + self.__layer = layer + self.setZValue(1.0/(1.0 + layer)) + + def final_location(self) -> (Node, QPointF): + if self.__animation_group is not None: + assert self.__final_pos is not None + return self.node(), self.__final_pos + else: + return self.node(), self.pos() + + def final_scene_position(self) -> QPointF: + fnode, fpos = self.final_location() + if fnode is not None: + fpos = fnode.mapToScene(fpos) + return fpos + + def is_in_animation(self): + return self.__animation_group is not None + + @Slot() + def _clear_animation_group(self): + if self.__animation_group is not None: + ag, self.__animation_group = self.__animation_group, None + ag.stop() # just in case some recursion occures + ag.deleteLater() + self.setParentItem(self.node()) + self.setPos(self.__final_pos) + self.set_layer(self.__final_layer) + self.__final_pos = None + self.__final_layer = None + + def set_task_position(self, node: Node, pos: QPointF, layer: int): + """ + set task position to given node and give pos/layer inside that node + also cancels any active move animation + """ + if self.__animation_group is not None: + self.__animation_group.stop() + self.__animation_group.deleteLater() + self.__animation_group = None + + self.setParentItem(node) + if pos is not None: + self.setPos(pos) + if layer is not None: + self.set_layer(layer) + + def append_task_move_animation(self, node: Node, pos: QPointF, layer: int): + """ + set task position to given node and give pos/layer inside that node, + but do it with animation + """ + # first try to optimize, if we move on the same node to invisible layer - don't animate + if node == self.node() and layer >= self.__visible_layers_count and self.__animation_group is None: + return self.set_task_position(node, pos, layer) + + # + dist = ((pos if node is None else node.mapToScene(pos)) - self.final_scene_position()) + ldist = sqrt(QPointF.dotProduct(dist, dist)) + self.set_layer(0) + animgroup = self.__animation_group + if animgroup is None: + animgroup = QSequentialAnimationGroup(self.scene()) + animgroup.finished.connect(self._clear_animation_group) + anim_speed = max(1.0, animgroup.animationCount() - 2) # -2 to start speedup only after a couple anims in queue + start_node, start_pos = self.final_location() + new_animation = TaskAnimation(self, start_node, start_pos, node, pos, duration=max(1, int(ldist / anim_speed)), parent=animgroup) + if self.__animation_group is None: + self.setParentItem(None) + self.__animation_group = animgroup + + self.__final_pos = pos + self.__final_layer = layer + # turns out i do NOT need to add animation to group IF animgroup was passed as parent to animation - it's added automatically + # self.__animation_group.addAnimation(new_animation) + if self.__animation_group.state() != QAbstractAnimation.Running: + self.__animation_group.start() + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemSceneChange: + if value is None: # removing item from scene + if self.__animation_group is not None: + self.__animation_group.stop() + self.__animation_group.clear() + self.__animation_group.deleteLater() + self.__animation_group = None + if self.node() is not None: + self.node().remove_task(self) + return super().itemChange(change, value) + + def hoverMoveEvent(self, event): + self.__hoverover_pos = event.pos() + + def hoverLeaveEvent(self, event): + self.__hoverover_pos = None + self.update() \ No newline at end of file diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/__init__.py b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/implicit_split_visualizer.py b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/implicit_split_visualizer.py new file mode 100644 index 00000000..662675db --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/implicit_split_visualizer.py @@ -0,0 +1,70 @@ +from PySide2.QtWidgets import QWidget, QStyleOptionGraphicsItem +from PySide2.QtCore import Qt, Slot, QRectF, QPointF +from PySide2.QtGui import QPainterPath, QPainter, QBrush, QPen, QColor +from typing import Optional + +from ..node_decorator_base import NodeDecorator +from ..drawable_node import DrawableNode + +from typing import Tuple + + +class ImplicitSplitVisualizer(NodeDecorator): + _arc_path: QPainterPath = None + _text_pen = None + + def __init__(self, parent_node: DrawableNode): + super(ImplicitSplitVisualizer, self).__init__(parent_node) + self.__output_names: Tuple[str, ...] = () + self.__output_poss: Tuple[QPointF, ...] = () + + self.__brush = QBrush(QColor.fromRgbF(0.9, 0.6, 0.2, 0.2)) + self.__text_bounds = QRectF(-20, 15, 40, 16) + + if self._arc_path is None: + newpath = QPainterPath() + arcbb = QRectF(-40, 0, 80, 50) + newpath.arcMoveTo(arcbb, 225) + newpath.arcTo(arcbb, 225, 90) + arcbb1 = QRectF(arcbb) + arcbb1.setTopLeft(arcbb.topLeft() * 0.8) + arcbb1.setBottomRight(arcbb.bottomRight() * 0.8) + newpath.arcTo(arcbb1, 315, -90) + newpath.closeSubpath() + + ImplicitSplitVisualizer._arc_path = newpath + ImplicitSplitVisualizer._text_pen = QPen(QColor.fromRgbF(1, 1, 1, 0.2)) + + def boundingRect(self) -> QRectF: + arcrect = self._arc_path.boundingRect() + if self.__output_poss: + rect: QRectF = QRectF( + QPointF(min(p.x() for p in self.__output_poss), min(p.x() for p in self.__output_poss)), + QPointF(max(p.x() for p in self.__output_poss), max(p.x() for p in self.__output_poss)) + ) + else: + rect: QRectF = QRectF() + rect.united(self.__text_bounds) + return QRectF( + rect.left() + arcrect.left(), + arcrect.top(), + rect.width() + arcrect.width(), + arcrect.height() + ).adjusted(-1, -1, 1, 1) # why adjusted? just not to forget later to adjust when pen gets involved. currently it's not needed + + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: + for pos, name in zip(self.__output_poss, self.__output_names): + if len(self.node().output_connections(name)) <= 1: + continue + painter.fillPath(self._arc_path.translated(pos), self.__brush) + painter.setPen(self._text_pen) + painter.drawText(self.__text_bounds.translated(pos), Qt.AlignHCenter | Qt.AlignTop, 'split') + + def node_updated(self): + output_names = self.node().output_names() + output_poss = tuple(self.node().get_output_position(name, local=True) for name in output_names) + if output_names == self.__output_names and output_poss == self.__output_poss: + return + self.__output_names = output_names + self.__output_poss = output_poss + self.prepareGeometryChange() diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node.py b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node.py new file mode 100644 index 00000000..f1879fbe --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node.py @@ -0,0 +1,409 @@ +import imgui +from lifeblood import logging +from lifeblood.config import get_config +from lifeblood.enums import NodeParameterType +from lifeblood.uidata import CollapsableVerticalGroup, OneLineParametersLayout, Parameter, ParameterExpressionError, ParametersLayoutBase, Separator, NodeUi +from lifeblood_viewer.graphics_items import Node +from ...utils import call_later +from ..decorated_node import DecoratedNode +from ..node_connection_create_preview import NodeConnectionCreatePreview +from ...node_connection_snap_point import NodeConnSnapPoint + +from lifeblood_viewer.scene_data_controller import SceneDataController +from lifeblood_viewer.code_editor.editor import StringParameterEditor +from lifeblood_viewer.graphics_scene_container import GraphicsSceneWithNodesAndTasks +from lifeblood_viewer.graphics_scene_viewing_widget import GraphicsSceneViewingWidgetBase +from ..node_decorator_base import NodeDecoratorFactoryBase + +from PySide2.QtCore import Qt, Slot, QPointF +from PySide2.QtGui import QDesktopServices, QPainter +from PySide2.QtWidgets import QGraphicsItem, QStyleOptionGraphicsItem, QGraphicsSceneMouseEvent, QWidget + +from typing import Iterable, Optional + + +logger = logging.get_logger('viewer') + + +class SceneNode(DecoratedNode): + base_height = 100 + base_width = 150 + + def __init__(self, scene: GraphicsSceneWithNodesAndTasks, id: int, type: str, name: str, data_controller: SceneDataController, node_decorator_factories: Iterable[NodeDecoratorFactoryBase] = ()): + super().__init__(scene, id, type, name, node_decorator_factories) + self.__scene_container = scene + self.__data_controller: SceneDataController = data_controller + + # display + self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable | QGraphicsItem.ItemSendsGeometryChanges) + self.setAcceptHoverEvents(True) + self.__nodeui_menucache = {} + self.__ui_selected_tab = 0 + + self.__ui_interactor = None + self.__ui_grabbed_conn = None + self.__ui_widget: Optional[GraphicsSceneViewingWidgetBase] = None + + self.__move_start_position = None + self.__move_start_selection = None + + self.__node_ui_for_io_requested = False + + # misc + self.__manual_url_base = get_config('viewer').get_option_noasync('manual_base_url', 'https://pedohorse.github.io/lifeblood') + + def apply_settings(self, settings_name: str): + self.__data_controller.request_apply_node_settings(self.get_id(), settings_name) + + def pause_all_tasks(self): + self.__data_controller.set_tasks_paused([x.get_id() for x in self.tasks_iter()], True) + + def resume_all_tasks(self): + self.__data_controller.set_tasks_paused([x.get_id() for x in self.tasks_iter()], False) + + def update_nodeui(self, nodeui: NodeUi): + super().update_nodeui(nodeui) + self.__nodeui_menucache = {} + + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: + # TODO: this request from paint here is SUS, guess it's needed to ensure inputs-outputs and color are displayed properly + if not self.__node_ui_for_io_requested: + self.__node_ui_for_io_requested = True + self.__data_controller.request_node_ui(self.get_id()) + + super().paint(painter, option, widget) + + # + # interface + + # helper + def __draw_single_item(self, item, size=(1.0, 1.0), drawing_widget=None): + if isinstance(item, Parameter): + if not item.visible(): + return + param_name = item.name() + param_label = item.label() or '' + parent_layout = item.parent() + idstr = f'_{self.get_id()}' + assert isinstance(parent_layout, ParametersLayoutBase) + imgui.push_item_width(imgui.get_window_width() * parent_layout.relative_size_for_child(item)[0] * 2 / 3) + + changed = False + expr_changed = False + + new_item_val = None + new_item_expression = None + + try: + if item.has_expression(): + with imgui.colored(imgui.COLOR_FRAME_BACKGROUND, 0.1, 0.4, 0.1): + expr_changed, newval = imgui.input_text('##'.join((param_label, param_name, idstr)), item.expression(), 256, flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) + if expr_changed: + new_item_expression = newval + elif item.has_menu(): + menu_order, menu_items = item.get_menu_items() + + if param_name not in self.__nodeui_menucache: + self.__nodeui_menucache[param_name] = {'menu_items_inv': {v: k for k, v in menu_items.items()}, + 'menu_order_inv': {v: i for i, v in enumerate(menu_order)}} + + menu_items_inv = self.__nodeui_menucache[param_name]['menu_items_inv'] + menu_order_inv = self.__nodeui_menucache[param_name]['menu_order_inv'] + if item.is_readonly() or item.is_locked(): # TODO: treat locked items somehow different, but for now it's fine + imgui.text(menu_items_inv[item.value()]) + return + else: + changed, val = imgui.combo('##'.join((param_label, param_name, idstr)), menu_order_inv[menu_items_inv[item.value()]], menu_order) + if changed: + new_item_val = menu_items[menu_order[val]] + else: + if item.is_readonly() or item.is_locked(): # TODO: treat locked items somehow different, but for now it's fine + imgui.text(f'{item.value()}') + if item.label(): + imgui.same_line() + imgui.text(f'{item.label()}') + return + param_type = item.type() + if param_type == NodeParameterType.BOOL: + changed, newval = imgui.checkbox('##'.join((param_label, param_name, idstr)), item.value()) + elif param_type == NodeParameterType.INT: + #changed, newval = imgui.slider_int('##'.join((param_label, param_name, idstr)), item.value(), 0, 10) + slider_limits = item.display_value_limits() + if slider_limits[0] is not None: + changed, newval = imgui.slider_int('##'.join((param_label, param_name, idstr)), item.value(), *slider_limits) + else: + changed, newval = imgui.input_int('##'.join((param_label, param_name, idstr)), item.value(), flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) + if imgui.begin_popup_context_item(f'item context menu##{param_name}', 2): + imgui.selectable('toggle expression') + imgui.end_popup() + elif param_type == NodeParameterType.FLOAT: + #changed, newval = imgui.slider_float('##'.join((param_label, param_name, idstr)), item.value(), 0, 10) + slider_limits = item.display_value_limits() + if slider_limits[0] is not None and slider_limits[1] is not None: + changed, newval = imgui.slider_float('##'.join((param_label, param_name, idstr)), item.value(), *slider_limits) + else: + changed, newval = imgui.input_float('##'.join((param_label, param_name, idstr)), item.value(), flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) + elif param_type == NodeParameterType.STRING: + if item.is_text_multiline(): + # TODO: this below is a temporary solution. it only gives 8192 extra symbols for editing, but currently there is no proper way around with current pyimgui version + imgui.begin_group() + ed_butt_pressed = imgui.small_button(f'open in external window##{param_name}') + changed, newval = imgui.input_text_multiline('##'.join((param_label, param_name, idstr)), item.unexpanded_value(), len(item.unexpanded_value()) + 1024*8, flags=imgui.INPUT_TEXT_ALLOW_TAB_INPUT | imgui.INPUT_TEXT_ENTER_RETURNS_TRUE | imgui.INPUT_TEXT_CTRL_ENTER_FOR_NEW_LINE) + imgui.end_group() + if ed_butt_pressed: + hl = StringParameterEditor.SyntaxHighlight.NO_HIGHLIGHT + if item.syntax_hint() == 'python': + hl = StringParameterEditor.SyntaxHighlight.PYTHON + wgt = StringParameterEditor(syntax_highlight=hl, parent=drawing_widget) + wgt.setAttribute(Qt.WA_DeleteOnClose, True) + wgt.set_text(item.unexpanded_value()) + wgt.edit_done.connect(lambda x, sc=self.scene(), id=self.get_id(), it=item: sc.change_node_parameter(id, item, x)) + wgt.set_title(f'editing parameter "{param_name}"') + wgt.show() + else: + changed, newval = imgui.input_text('##'.join((param_label, param_name, idstr)), item.unexpanded_value(), 256, flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE) + else: + raise NotImplementedError() + if changed: + new_item_val = newval + + # item context menu popup + popupid = '##'.join((param_label, param_name, idstr)) # just to make sure no names will collide with full param imgui lables + if imgui.begin_popup_context_item(f'Item Context Menu##{popupid}', 2): + if item.can_have_expressions() and not item.has_expression(): + if imgui.selectable(f'enable expression##{popupid}')[0]: + expr_changed = True + # try to turn backtick expressions into normal one + if item.type() == NodeParameterType.STRING: + new_item_expression = item.python_from_expandable_string(item.unexpanded_value()) + else: + new_item_expression = str(item.value()) + if item.has_expression(): + if imgui.selectable(f'delete expression##{popupid}')[0]: + try: + value = item.value() + except ParameterExpressionError as e: + value = item.default_value() + expr_changed = True + changed = True + new_item_val = value + new_item_expression = None + imgui.end_popup() + finally: + imgui.pop_item_width() + + if changed or expr_changed: + # TODO: op below may fail, so callback to display error should be provided + self.__data_controller.change_node_parameter(self.get_id(), item, + new_item_val if changed else ..., + new_item_expression if expr_changed else ...) + + elif isinstance(item, Separator): + imgui.separator() + elif isinstance(item, OneLineParametersLayout): + first_time = True + for child in item.items(recursive=False): + h, w = item.relative_size_for_child(child) + if isinstance(child, Parameter): + if not child.visible(): + continue + if first_time: + first_time = False + else: + imgui.same_line() + self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) + elif isinstance(item, CollapsableVerticalGroup): + expanded, _ = imgui.collapsing_header(f'{item.label()}##{item.name()}') + if expanded: + imgui.indent(5) + for child in item.items(recursive=False): + h, w = item.relative_size_for_child(child) + self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) + imgui.unindent(5) + imgui.separator() + elif isinstance(item, ParametersLayoutBase): + imgui.indent(5) + for child in item.items(recursive=False): + h, w = item.relative_size_for_child(child) + if isinstance(child, Parameter): + if not child.visible(): + continue + self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) + imgui.unindent(5) + elif isinstance(item, ParametersLayoutBase): + for child in item.items(recursive=False): + h, w = item.relative_size_for_child(child) + if isinstance(child, Parameter): + if not child.visible(): + continue + self.__draw_single_item(child, (h*size[0], w*size[1]), drawing_widget=drawing_widget) + else: + raise NotImplementedError(f'unknown parameter hierarchy item to display {type(item)}') + + # main dude + def draw_imgui_elements(self, drawing_widget): + imgui.text(f'Node {self.get_id()}, type "{self.node_type()}", name {self.node_name()}') + + if imgui.selectable(f'parameters##{self.node_name()}', self.__ui_selected_tab == 0, width=imgui.get_window_width() * 0.5 * 0.7)[1]: + self.__ui_selected_tab = 0 + imgui.same_line() + if imgui.selectable(f'description##{self.node_name()}', self.__ui_selected_tab == 1, width=imgui.get_window_width() * 0.5 * 0.7)[1]: + self.__ui_selected_tab = 1 + imgui.separator() + + if self.__ui_selected_tab == 0: + if (nodeui := self.get_nodeui()) is not None: + self.__draw_single_item(nodeui.main_parameter_layout(), drawing_widget=drawing_widget) + elif self.__ui_selected_tab == 1: + + if (node_type := self.node_type()) in self.__data_controller.node_types() and imgui.button('open manual page'): + plugin_info = self.__data_controller.node_types()[node_type].plugin_info + category = plugin_info.category + package = plugin_info.package_name + QDesktopServices.openUrl(self.__manual_url_base + f'/nodes/{category}{f"/{package}" if package else ""}/{self.node_type()}.html') + imgui.text(self.__data_controller.node_types()[self.node_type()].description if self.node_type() in self.__data_controller.node_types() else 'error') + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemSelectedHasChanged: + if value and self.graphics_scene().get_inspected_item() == self: # item was just selected, And is the first selected + self.__data_controller.request_node_ui(self.get_id()) + elif change == QGraphicsItem.ItemPositionChange: + if self.__move_start_position is None: + self.__move_start_position = self.pos() + for connection in self.all_connections(): + connection.prepareGeometryChange() + + return super().itemChange(change, value) + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent): + if event.button() == Qt.LeftButton and self.__ui_interactor is None: + wgt = event.widget().parent() + assert isinstance(wgt, GraphicsSceneViewingWidgetBase) + pos = event.scenePos() + r2 = (self._input_radius() + 0.5*self._line_width())**2 + + # check expand button + expand_button_shape = self._get_expandbutton_shape() + if expand_button_shape.contains(event.pos()): + self.set_expanded(not self.is_expanded()) + event.ignore() + return + + for input in self.input_names(): + inpos = self.get_input_position(input) + if QPointF.dotProduct(inpos - pos, inpos - pos) <= r2 and wgt.request_ui_focus(self): + snap_points = [y for x in self.__scene_container.nodes() if x != self for y in x.output_snap_points()] + displayer = NodeConnectionCreatePreview(None, self, '', input, snap_points, 15, self._ui_interactor_finished) + self.scene().addItem(displayer) + self.__ui_interactor = displayer + self.__ui_grabbed_conn = input + self.__ui_widget = wgt + event.accept() + self.__ui_interactor.mousePressEvent(event) + return + + for output in self.output_names(): + outpos = self.get_output_position(output) + if QPointF.dotProduct(outpos - pos, outpos - pos) <= r2 and wgt.request_ui_focus(self): + snap_points = [y for x in self.__scene_container.nodes() if x != self for y in x.input_snap_points()] + displayer = NodeConnectionCreatePreview(self, None, output, '', snap_points, 15, self._ui_interactor_finished) + self.scene().addItem(displayer) + self.__ui_interactor = displayer + self.__ui_grabbed_conn = output + self.__ui_widget = wgt + event.accept() + self.__ui_interactor.mousePressEvent(event) + return + + if not self._get_nodeshape().contains(event.pos()): + event.ignore() + return + + super().mousePressEvent(event) + self.__move_start_selection = {self} + self.__move_start_position = None + + # check for special picking: shift+move should move all upper connected nodes + if event.modifiers() & Qt.ShiftModifier or event.modifiers() & Qt.ControlModifier: + selecting_inputs = event.modifiers() & Qt.ShiftModifier + selecting_outputs = event.modifiers() & Qt.ControlModifier + extra_selected_nodes = set() + if selecting_inputs: + extra_selected_nodes.update(self.input_nodes()) + if selecting_outputs: + extra_selected_nodes.update(self.output_nodes()) + + extra_selected_nodes_ordered = list(extra_selected_nodes) + for relnode in extra_selected_nodes_ordered: + relnode.setSelected(True) + relrelnodes = set() + if selecting_inputs: + relrelnodes.update(node for node in relnode.input_nodes() if node not in extra_selected_nodes) + if selecting_outputs: + relrelnodes.update(node for node in relnode.output_nodes() if node not in extra_selected_nodes) + extra_selected_nodes_ordered.extend(relrelnodes) + extra_selected_nodes.update(relrelnodes) + self.setSelected(True) + for item in self.scene().selectedItems(): + if isinstance(item, Node): + self.__move_start_selection.add(item) + item.__move_start_position = None + + if event.button() == Qt.RightButton: + # context menu time + view = event.widget().parent() + assert isinstance(view, GraphicsSceneViewingWidgetBase) + view.item_requests_context_menu(self) + event.accept() + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent): + # if self.__ui_interactor is not None: + # event.accept() + # self.__ui_interactor.mouseMoveEvent(event) + # return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): + # if self.__ui_interactor is not None: + # event.accept() + # self.__ui_interactor.mouseReleaseEvent(event) + # return + super().mouseReleaseEvent(event) + if self.__move_start_position is not None: + if self.__scene_container.node_snapping_enabled(): + for node in self.__move_start_selection: + pos = node.pos() + snapx = node.base_width / 4 + snapy = node.base_height / 4 + node.setPos(round(pos.x() / snapx) * snapx, + round(pos.y() / snapy) * snapy) + self.scene()._nodes_were_moved([(node, node.__move_start_position) for node in self.__move_start_selection]) + for node in self.__move_start_selection: + node.__move_start_position = None + + @Slot(object) + def _ui_interactor_finished(self, snap_point: Optional[NodeConnSnapPoint]): + assert self.__ui_interactor is not None + call_later(lambda x: logger.debug(f'later removing {x}') or x.scene().removeItem(x), self.__ui_interactor) + if self.scene() is None: # if scheduler deleted us while interacting + return + if self.__ui_widget is None: + raise RuntimeError('interaction finalizer called, but ui widget is not set') + + grabbed_conn = self.__ui_grabbed_conn + self.__ui_widget.release_ui_focus(self) + self.__ui_widget = None + self.__ui_interactor = None + self.__ui_grabbed_conn = None + + # actual node reconection + if snap_point is None: + logger.debug('no change') + return + + setting_out = not snap_point.connection_is_input() + self.__data_controller.add_connection(snap_point.node().get_id() if setting_out else self.get_id(), + snap_point.connection_name() if setting_out else grabbed_conn, + snap_point.node().get_id() if not setting_out else self.get_id(), + snap_point.connection_name() if not setting_out else grabbed_conn) + diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node_connection.py b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node_connection.py new file mode 100644 index 00000000..4adf2603 --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node_connection.py @@ -0,0 +1,265 @@ +from math import sqrt +from lifeblood import logging +from lifeblood_viewer.graphics_items import Node, NodeConnection +from ...utils import call_later, length2 +from ..node_connection_create_preview import NodeConnectionCreatePreview +from ...node_connection_snap_point import NodeConnSnapPoint + +from lifeblood_viewer.scene_data_controller import SceneDataController +from lifeblood_viewer.graphics_scene_container import GraphicsSceneWithNodesAndTasks +from lifeblood_viewer.graphics_scene_viewing_widget import GraphicsSceneViewingWidgetBase + +from PySide2.QtCore import Qt, Slot, QPointF, QRectF +from PySide2.QtGui import QColor, QPainter, QPainterPath, QPainterPathStroker, QPen +from PySide2.QtWidgets import QGraphicsItem, QStyleOptionGraphicsItem, QGraphicsSceneMouseEvent, QWidget + +from typing import Optional + + +logger = logging.get_logger('viewer') + + +class SceneNodeConnection(NodeConnection): + def __init__(self, scene: GraphicsSceneWithNodesAndTasks, id: int, nodeout: Node, nodein: Node, outname: str, inname: str, data_controller: SceneDataController): + super().__init__(scene, id, nodeout, nodein, outname, inname) + self.__scene_container = scene + self.__data_controller: SceneDataController = data_controller + self.setFlags(QGraphicsItem.ItemSendsGeometryChanges) # QGraphicsItem.ItemIsSelectable | + self.setAcceptHoverEvents(True) # for highlights + + self.setZValue(-1) + self.__line_width = 6 # TODO: rename it to match what it represents + self.__wire_pick_radius = 15 + self.__pick_radius2 = 100 ** 2 + self.__curv = 150 + self.__wire_highlight_radius = 5 + + self.__temporary_invalid = False + + self.__ui_interactor: Optional[NodeConnectionCreatePreview] = None + + self.__ui_last_pos = QPointF() + self.__ui_grabbed_beginning: bool = True + + self.__pen = QPen(QColor(64, 64, 64, 192)) + self.__pen.setWidthF(3) + self.__pen_highlight = QPen(QColor(92, 92, 92, 192)) + self.__pen_highlight.setWidthF(3) + self.__thick_pen = QPen(QColor(144, 144, 144, 128)) + self.__thick_pen.setWidthF(4) + self.__last_drawn_path: Optional[QPainterPath] = None + + self.__stroker = QPainterPathStroker() + self.__stroker.setWidth(2 * self.__wire_pick_radius) + + self.__hoverover_pos = None + + # to ensure correct interaction + self.__ui_widget: Optional[GraphicsSceneViewingWidgetBase] = None + + def distance_to_point(self, pos: QPointF): + """ + returns approx distance to a given point + currently it has the most crude implementation + :param pos: + :return: + """ + + line = self.get_painter_path() + # determine where to start + outnode, outname = self.output() + innode, inname = self.input() + p0 = outnode.get_output_position(outname) + p1 = innode.get_input_position(inname) + + if length2(p0-pos) < length2(p1-pos): # pos closer to p0 + curper = 0 + curstep = 0.1 + lastsqlen = length2(p0 - pos) + else: + curper = 1 + curstep = -0.1 + lastsqlen = length2(p1 - pos) + + sqlen = lastsqlen + while 0 <= curper <= 1: + curper += curstep + sqlen = length2(line.pointAtPercent(curper) - pos) + if sqlen > lastsqlen: + curstep *= -0.1 + if abs(sqlen - lastsqlen) < 0.001**2 or abs(curstep) < 1e-7: + break + lastsqlen = sqlen + + return sqrt(sqlen) + + def boundingRect(self) -> QRectF: + outnode, outname = self.output() + innode, inname = self.input() + if outname not in outnode.output_names() or inname not in innode.input_names(): + self.__temporary_invalid = True + return QRectF() + self.__temporary_invalid = False + hlw = self.__line_width + line = self.get_painter_path() + return line.boundingRect().adjusted(-hlw - self.__wire_pick_radius, -hlw, hlw + self.__wire_pick_radius, hlw) + + def shape(self): + # this one is mainly needed for proper selection and item picking + return self.__stroker.createStroke(self.get_painter_path()) + + def get_painter_path(self, close_path=False): + line = QPainterPath() + + outnode, outname = self.output() + innode, inname = self.input() + p0 = outnode.get_output_position(outname) + p1 = innode.get_input_position(inname) + curv = self.__curv + curv = min((p0-p1).manhattanLength()*0.5, curv) + line.moveTo(p0) + line.cubicTo(p0 + QPointF(0, curv), p1 - QPointF(0, curv), p1) + if close_path: + line.cubicTo(p1 - QPointF(0, curv), p0 + QPointF(0, curv), p0) + return line + + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: + if self.__temporary_invalid: + return + if self.__ui_interactor is not None: # if interactor exists - it does all the drawing + return + line = self.get_painter_path() + + painter.setPen(self.__pen) + + if self.__hoverover_pos is not None: + hldiag = QPointF(self.__wire_highlight_radius, self.__wire_highlight_radius) + if line.intersects(QRectF(self.__hoverover_pos - hldiag, self.__hoverover_pos + hldiag)): + painter.setPen(self.__pen_highlight) + + if self.isSelected(): + painter.setPen(self.__thick_pen) + + painter.drawPath(line) + # painter.drawRect(self.boundingRect()) + self.__last_drawn_path = line + + def hoverMoveEvent(self, event): + self.__hoverover_pos = event.pos() + + def hoverLeaveEvent(self, event): + self.__hoverover_pos = None + self.update() + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent): + event.ignore() + if event.button() != Qt.LeftButton: + return + line = self.get_painter_path(close_path=True) + circle = QPainterPath() + circle.addEllipse(event.scenePos(), self.__wire_pick_radius, self.__wire_pick_radius) + if self.__ui_interactor is None and line.intersects(circle): + logger.debug('wire candidate for picking detected') + wgt = event.widget() + if wgt is None: + return + + p = event.scenePos() + outnode, outname = self.output() + innode, inname = self.input() + p0 = outnode.get_output_position(outname) + p1 = innode.get_input_position(inname) + d02 = QPointF.dotProduct(p0 - p, p0 - p) + d12 = QPointF.dotProduct(p1 - p, p1 - p) + if d02 > self.__pick_radius2 and d12 > self.__pick_radius2: # if picked too far from ends - just select + super().mousePressEvent(event) + event.accept() + return + + # this way we report to scene event handler that we are candidates for picking + if hasattr(event, 'item_event_candidates'): + event.item_event_candidates.append((self.distance_to_point(p), self)) + + def post_mousePressEvent(self, event: QGraphicsSceneMouseEvent): + """ + this will be called by scene as continuation of mousePressEvent + IF scene decides so. + :param event: + :return: + """ + wgt = event.widget().parent() + p = event.scenePos() + outnode, outname = self.output() + innode, inname = self.input() + p0 = outnode.get_output_position(outname) + p1 = innode.get_input_position(inname) + d02 = QPointF.dotProduct(p0 - p, p0 - p) + d12 = QPointF.dotProduct(p1 - p, p1 - p) + + assert isinstance(wgt, GraphicsSceneViewingWidgetBase) + if wgt.request_ui_focus(self): + event.accept() + + output_picked = d02 < d12 + if output_picked: + snap_points = [y for x in self.__scene_container.nodes() if x != innode for y in x.output_snap_points()] + else: + snap_points = [y for x in self.__scene_container.nodes() if x != outnode for y in x.input_snap_points()] + self.__ui_interactor = NodeConnectionCreatePreview(None if output_picked else outnode, + innode if output_picked else None, + outname, inname, + snap_points, 15, self._ui_interactor_finished, True) + self.update() + self.__ui_widget = wgt + self.scene().addItem(self.__ui_interactor) + self.__ui_interactor.mousePressEvent(event) + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: + # if self.__ui_interactor is not None: # redirect input, cuz scene will direct all events to this item. would be better to change focus, but so far scene.setFocusItem did not work as expected + # self.__ui_interactor.mouseMoveEvent(event) + # event.accept() + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: + # event.ignore() + # if event.button() != Qt.LeftButton: + # return + # if self.__ui_interactor is not None: # redirect input, cuz scene will direct all events to this item. would be better to change focus, but so far scene.setFocusItem did not work as expected + # self.__ui_interactor.mouseReleaseEvent(event) + # event.accept() + # self.ungrabMouse() + logger.debug('ungrabbing mouse') + self.ungrabMouse() + super().mouseReleaseEvent(event) + + @Slot(object) + def _ui_interactor_finished(self, snap_point: Optional[NodeConnSnapPoint]): + assert self.__ui_interactor is not None + call_later(lambda x: logger.debug(f'later removing {x}') or x.scene().removeItem(x), self.__ui_interactor) + if self.scene() is None: # if scheduler deleted us while interacting + return + # NodeConnection._dbg_shitlist.append(self.__ui_interactor) + self.__ui_widget.release_ui_focus(self) + self.__ui_widget = None + is_cutting = self.__ui_interactor.is_cutting() + self.__ui_interactor = None + self.update() + + # are we cutting the wire + if is_cutting: + self.__data_controller.cut_connection_by_id(self.get_id()) + return + + # actual node reconection + if snap_point is None: + logger.debug('no change') + return + + changing_out = not snap_point.connection_is_input() + self.__data_controller.change_connection_by_id( + self.get_id(), + to_outnode_id=snap_point.node().get_id() if changing_out else None, + to_outname=snap_point.connection_name() if changing_out else None, + to_innode_id=None if changing_out else snap_point.node().get_id(), + to_inname=None if changing_out else snap_point.connection_name() + ) diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_task.py b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_task.py new file mode 100644 index 00000000..86152631 --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_task.py @@ -0,0 +1,195 @@ +from datetime import timedelta +import imgui +from lifeblood import logging +from lifeblood.enums import InvocationState +from lifeblood.ui_protocol_data import TaskData, IncompleteInvocationLogData, InvocationLogData +from .scene_task_preview import SceneTaskPreview +from ..drawable_task import DrawableTask +from ...graphics_items import Node +from ...network_item_watchers import NetworkItemWatcher + +from ...utils import call_later + +from lifeblood_viewer.editor_scene_integration import fetch_and_open_log_viewer +from lifeblood_viewer.scene_data_controller import SceneDataController +from lifeblood_viewer.graphics_scene_container import GraphicsSceneWithNodesAndTasks +from lifeblood_viewer.graphics_scene_viewing_widget import GraphicsSceneViewingWidgetBase + +from PySide2.QtCore import Qt, QPointF +from PySide2.QtWidgets import QGraphicsItem, QGraphicsSceneMouseEvent + +from typing import Optional, Set + + +logger = logging.get_logger('viewer') + + +class SceneTask(DrawableTask): + def __init__(self, scene: GraphicsSceneWithNodesAndTasks, task_data: TaskData, data_controller: SceneDataController): + super().__init__(scene, task_data) + self.__scene_container = scene + self.__data_controller = data_controller + self.setAcceptHoverEvents(True) + # self.setFlags(QGraphicsItem.ItemIsSelectable) + + self.__ui_interactor: Optional[SceneTaskPreview] = None + self.__press_pos: Optional[QPointF] = None + + self.__requested_invocs_while_selected = set() + + def add_item_watcher(self, watcher: "NetworkItemWatcher"): + super().add_item_watcher(watcher) + # additionally refresh ui if we are not being watched + if len(self.item_watchers()) == 1: # it's a first watcher + self.refresh_ui() + + def set_name(self, name: str): + super().set_name(name) + self.refresh_ui() + + def set_groups(self, groups: Set[str]): + super().set_groups(groups) + self.refresh_ui() + + def refresh_ui(self): + """ + unlike update - this method actually queries new task ui status + if task is not selected or not watched- does nothing + :return: + """ + if not self.isSelected() and len(self.item_watchers()) == 0: + return + self.__data_controller.request_log_meta(self.get_id()) # update all task metadata: which nodes it ran on and invocation numbers only + self.__data_controller.request_attributes(self.get_id()) + + for invoc_id, nid, invoc_dict in self.invocation_logs(): + if invoc_dict is None: + continue + if (isinstance(invoc_dict, IncompleteInvocationLogData) + or invoc_dict.invocation_state != InvocationState.FINISHED) and invoc_id in self.__requested_invocs_while_selected: + self.__requested_invocs_while_selected.remove(invoc_id) + + def itemChange(self, change, value): + if change == QGraphicsItem.ItemSelectedHasChanged: + if value and self.node() is not None: # item was just selected + self.refresh_ui() + elif not value: + self.setFlag(QGraphicsItem.ItemIsSelectable, False) # we are not selectable any more by band selection until directly clicked + pass + return super().itemChange(change, value) + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: + if not self._get_selectshapepath().contains(event.pos()): + event.ignore() + return + self.setFlag(QGraphicsItem.ItemIsSelectable, True) # if we are clicked - we are now selectable until unselected. This is to avoid band selection + super().mousePressEvent(event) + self.__press_pos = event.scenePos() + + if event.button() == Qt.RightButton: + # context menu time + view = event.widget().parent() + assert isinstance(view, GraphicsSceneViewingWidgetBase) + view.item_requests_context_menu(self) + event.accept() + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: + if self.__ui_interactor is None: + movedist = event.scenePos() - self.__press_pos + if QPointF.dotProduct(movedist, movedist) > 2500: # TODO: config this rad squared + self.__ui_interactor = SceneTaskPreview(self) + self.scene().addItem(self.__ui_interactor) + if self.__ui_interactor: + self.__ui_interactor.mouseMoveEvent(event) + else: + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: + if self.__ui_interactor: + self.__ui_interactor.mouseReleaseEvent(event) + nodes = [x for x in self.scene().items(event.scenePos(), Qt.IntersectsItemBoundingRect) if isinstance(x, Node)] # TODO: dirty, implement such method in one of scene subclasses + if len(nodes) > 0: + logger.debug(f'moving item {self} to node {nodes[0]}') + self.__data_controller.request_set_task_node(self.get_id(), nodes[0].get_id()) + call_later(self.__ui_interactor.scene().removeItem, self.__ui_interactor) + self.__ui_interactor = None + + else: + super().mouseReleaseEvent(event) + + @staticmethod + def _draw_dict_table(attributes: dict, table_name: str): + imgui.columns(2, table_name) + imgui.separator() + imgui.text('name') + imgui.next_column() + imgui.text('value') + imgui.next_column() + imgui.separator() + for key, val in attributes.items(): + imgui.text(key) + imgui.next_column() + imgui.text(repr(val)) + imgui.next_column() + imgui.columns(1) + + # + # interface + def draw_imgui_elements(self, drawing_widget): + imgui.text(f'Task {self.get_id()} {self.name()}') + imgui.text(f'state: {self.state().name}') + imgui.text(f'groups: {", ".join(self.groups())}') + imgui.text(f'parent id: {self.parent_task_id()}') + imgui.text(f'children count: {self.children_tasks_count()}') + imgui.text(f'split level: {self.split_level()}') + imgui.text(f'invocation attempts: {self.latest_invocation_attempt()}') + + # first draw attributes + if self.attributes(): + self._draw_dict_table(self.attributes(), 'node_task_attributes') + + if env_res_args := self.environment_attributes(): + tab_expanded, _ = imgui.collapsing_header(f'environment resolver attributes##collapsing_node_task_environment_resolver_attributes') + if tab_expanded: + imgui.text(f'environment resolver: "{env_res_args.name()}"') + if env_res_args.arguments(): + self._draw_dict_table(env_res_args.arguments(), 'node_task_environment_resolver_attributes') + + # now draw log + imgui.text('Logs:') + for node_id, invocs in self.invocation_logs_mapping().items(): + node: Node = self.__scene_container.get_node(node_id) + if node is None: + logger.warning(f'node for task {self.get_id()} does not exist') + continue + node_name: str = node.node_name() + node_expanded, _ = imgui.collapsing_header(f'node {node_id}' + (f' "{node_name}"' if node_name else '')) + if not node_expanded: # or invocs is None: + continue + for invoc_id, invoc_log in invocs.items(): + # TODO: pyimgui is not covering a bunch of fancy functions... watch when it's done + imgui.indent(10) + invoc_expanded, _ = imgui.collapsing_header(f'invocation {invoc_id}' + + (f', worker {invoc_log.worker_id}' if isinstance(invoc_log, InvocationLogData) is not None else '') + + f', time: {timedelta(seconds=round(invoc_log.invocation_runtime)) if invoc_log.invocation_runtime is not None else "N/A"}' + + f'###logentry_{invoc_id}') + if not invoc_expanded: + imgui.unindent(10) + continue + if invoc_id not in self.__requested_invocs_while_selected: + self.__requested_invocs_while_selected.add(invoc_id) + self.__data_controller.request_log(invoc_id) + if isinstance(invoc_log, IncompleteInvocationLogData): + imgui.text('...fetching...') + else: + if invoc_log.stdout: + if imgui.button(f'open in viewer##{invoc_id}'): + fetch_and_open_log_viewer(self.scene(), invoc_id, drawing_widget, update_interval=None if invoc_log.invocation_state == InvocationState.FINISHED else 5) + + imgui.text_unformatted(invoc_log.stdout or '...nothing here...') + if invoc_log.invocation_state == InvocationState.IN_PROGRESS: + if imgui.button('update'): + logger.debug('clicked') + if invoc_id in self.__requested_invocs_while_selected: + self.__requested_invocs_while_selected.remove(invoc_id) + imgui.unindent(10) diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_task_preview.py b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_task_preview.py new file mode 100644 index 00000000..afb210b3 --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_task_preview.py @@ -0,0 +1,46 @@ +from ...graphics_items import Task + +from PySide2.QtCore import QPointF, QRectF, QSizeF +from PySide2.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen +from PySide2.QtWidgets import QGraphicsItem, QStyleOptionGraphicsItem, QGraphicsSceneMouseEvent, QWidget + +from typing import Optional + + +class SceneTaskPreview(QGraphicsItem): + def __init__(self, task: Task): + super().__init__() + self.setZValue(10) + self.__size = 16 + self.__line_width = 1.5 + self.__finished_callback = None + self.setZValue(10) + + self.__borderpen = QPen(QColor(192, 192, 192, 255), self.__line_width) + self.__brush = QBrush(QColor(64, 64, 64, 128)) + + def boundingRect(self) -> QRectF: + lw = self.__line_width + return QRectF(QPointF(-0.5 * (self.__size + lw), -0.5 * (self.__size + lw)), + QSizeF(self.__size + lw, self.__size + lw)) + + def _get_mainpath(self) -> QPainterPath: + path = QPainterPath() + path.addEllipse(-0.5 * self.__size, -0.5 * self.__size, + self.__size, self.__size) + return path + + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: + path = self._get_mainpath() + brush = self.__brush + painter.fillPath(path, brush) + painter.setPen(self.__borderpen) + painter.drawPath(path) + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: + self.setPos(event.scenePos()) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): + if self.__finished_callback is not None: + self.__finished_callback(event.scenePos()) # not used for now not to overcomplicate + event.accept() diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/node_connection_create_preview.py b/src/lifeblood_viewer/graphics_items/pretty_items/node_connection_create_preview.py new file mode 100644 index 00000000..992a5a13 --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/pretty_items/node_connection_create_preview.py @@ -0,0 +1,135 @@ +from lifeblood_viewer.graphics_items import Node +from ..utils import length2 +from ..node_connection_snap_point import NodeConnSnapPoint + +from PySide2.QtCore import Qt, QPointF, QRectF +from PySide2.QtGui import QColor, QPainter, QPainterPath, QPen +from PySide2.QtWidgets import QGraphicsItem, QStyleOptionGraphicsItem, QGraphicsSceneMouseEvent, QWidget + +from typing import Callable, List, Optional + + +class NodeConnectionCreatePreview(QGraphicsItem): + def __init__(self, nodeout: Optional[Node], nodein: Optional[Node], outname: str, inname: str, snap_points: List[NodeConnSnapPoint], snap_radius: float, report_done_here: Callable, do_cutting: bool = False): + super().__init__() + assert nodeout is None and nodein is not None or \ + nodeout is not None and nodein is None + self.setFlags(QGraphicsItem.ItemSendsGeometryChanges) + self.setZValue(10) + self.__nodeout = nodeout + self.__nodein = nodein + self.__outname = outname + self.__inname = inname + self.__snappoints = snap_points + self.__snap_radius2 = snap_radius * snap_radius + self.setZValue(-1) + self.__line_width = 4 + self.__curv = 150 + self.__breakdist2 = 200**2 + + self.__ui_last_pos = QPointF() + self.__finished_callback = report_done_here + + self.__pen = QPen(QColor(64, 64, 64, 192)) + self.__pen.setWidthF(3) + + self.__do_cutting = do_cutting + self.__cutpen = QPen(QColor(96, 32, 32, 192)) + self.__cutpen.setWidthF(3) + self.__cutpen.setStyle(Qt.DotLine) + + self.__is_snapping = False + + self.__orig_pos: Optional[QPointF] = None + + def get_painter_path(self): + if self.__nodein is not None: + p0 = self.__ui_last_pos + p1 = self.__nodein.get_input_position(self.__inname) + else: + p0 = self.__nodeout.get_output_position(self.__outname) + p1 = self.__ui_last_pos + + curv = self.__curv + curv = min((p0 - p1).manhattanLength() * 0.5, curv) + + line = QPainterPath() + line.moveTo(p0) + line.cubicTo(p0 + QPointF(0, curv), p1 - QPointF(0, curv), p1) + return line + + def boundingRect(self) -> QRectF: + hlw = self.__line_width + + if self.__nodein is not None: + inputpos = self.__ui_last_pos + outputpos = self.__nodein.get_input_position(self.__inname) + else: + inputpos = self.__nodeout.get_output_position(self.__outname) + outputpos = self.__ui_last_pos + + return QRectF(QPointF(min(inputpos.x(), outputpos.x()) - hlw, min(inputpos.y(), outputpos.y()) - hlw), + QPointF(max(inputpos.x(), outputpos.x()) + hlw, max(inputpos.y(), outputpos.y()) + hlw)) + + def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None) -> None: + line = self.get_painter_path() + if self.is_cutting(): + painter.setPen(self.__cutpen) + else: + painter.setPen(self.__pen) + painter.drawPath(line) + # painter.drawRect(self.boundingRect()) + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent): + if event.button() != Qt.LeftButton: + event.ignore() + return + self.grabMouse() + pos = event.scenePos() + closest_snap = self.get_closest_snappoint(pos) + self.__is_snapping = False + if closest_snap is not None: + pos = closest_snap.pos() + self.__is_snapping = True + self.prepareGeometryChange() + self.__ui_last_pos = pos + if self.__orig_pos is None: + self.__orig_pos = pos + event.accept() + + def mouseMoveEvent(self, event): + pos = event.scenePos() + closest_snap = self.get_closest_snappoint(pos) + self.__is_snapping = False + if closest_snap is not None: + pos = closest_snap.pos() + self.__is_snapping = True + self.prepareGeometryChange() + self.__ui_last_pos = pos + if self.__orig_pos is None: + self.__orig_pos = pos + event.accept() + + def is_cutting(self): + """ + wether or not interactor is it cutting the wire state + :return: + """ + return self.__do_cutting and not self.__is_snapping and self.__orig_pos is not None and length2(self.__orig_pos - self.__ui_last_pos) > self.__breakdist2 + + def get_closest_snappoint(self, pos: QPointF) -> Optional[NodeConnSnapPoint]: + snappoints = [x for x in self.__snappoints if length2(x.pos() - pos) < self.__snap_radius2] + + if len(snappoints) == 0: + return None + + return min(snappoints, key=lambda x: length2(x.pos() - pos)) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent): + if event.button() != Qt.LeftButton: + event.ignore() + return + if self.__finished_callback is not None: + self.__finished_callback(self.get_closest_snappoint(event.scenePos())) + event.accept() + self.ungrabMouse() diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/node_decorator_base.py b/src/lifeblood_viewer/graphics_items/pretty_items/node_decorator_base.py new file mode 100644 index 00000000..08ce7c89 --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/pretty_items/node_decorator_base.py @@ -0,0 +1,23 @@ +from PySide2.QtWidgets import QGraphicsItem +from .drawable_node import DrawableNode + + +class NodeDecorator(QGraphicsItem): + def __init__(self, node: DrawableNode): + super().__init__() + self.__node = node + self.setZValue(-2) # draw behind everything + + def node(self) -> DrawableNode: + return self.__node + + def node_updated(self): + """ + called by the owning node when it's data changes + """ + pass + + +class NodeDecoratorFactoryBase: + def make_decorator(self, node: DrawableNode) -> NodeDecorator: + raise NotImplementedError() diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/task_animation.py b/src/lifeblood_viewer/graphics_items/pretty_items/task_animation.py new file mode 100644 index 00000000..5121232c --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/pretty_items/task_animation.py @@ -0,0 +1,42 @@ +from ..graphics_items import Node, Task + +from PySide2.QtCore import QAbstractAnimation, QPointF + + +class TaskAnimation(QAbstractAnimation): + def __init__(self, task: "Task", node1: "Node", pos1: "QPointF", node2: "Node", pos2: "QPointF", duration: int, parent): + super().__init__(parent) + self.__task = task + + self.__node1 = node1 + self.__pos1 = pos1 + self.__node2 = node2 + self.__pos2 = pos2 + self.__duration = max(duration, 1) + self.__started = False + self.__anim_type = 0 if self.__node1 is self.__node2 else 1 + + def duration(self) -> int: + return self.__duration + + def updateCurrentTime(self, currentTime: int) -> None: + if not self.__started: + self.__started = True + + pos1 = self.__pos1 + if self.__node1: + pos1 = self.__node1.mapToScene(pos1) + + pos2 = self.__pos2 + if self.__node2: + pos2 = self.__node2.mapToScene(pos2) + + t = currentTime / self.duration() + if self.__anim_type == 0: # linear + pos = pos1 * (1 - t) + pos2 * t + else: # cubic + curv = min((pos2-pos1).manhattanLength() * 2, 1000) # 1000 is kinda derivative + a = QPointF(0, curv) - (pos2-pos1) + b = QPointF(0, -curv) + (pos2-pos1) + pos = pos1*(1-t) + pos2*t + t*(1-t)*(a*(1-t) + b*t) + self.__task.setPos(pos) diff --git a/src/lifeblood_viewer/graphics_items/qextended_graphics_item.py b/src/lifeblood_viewer/graphics_items/qextended_graphics_item.py new file mode 100644 index 00000000..a1d2d107 --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/qextended_graphics_item.py @@ -0,0 +1,17 @@ +from PySide2.QtWidgets import QGraphicsItem, QGraphicsSceneMouseEvent + + +class QGraphicsItemExtended(QGraphicsItem): + def __init__(self): + super().__init__() + # cheat cuz Shiboken.Object does not respect mro + mro = self.__class__.mro() + cur_mro_i = mro.index(QGraphicsItemExtended) + if len(mro) > cur_mro_i + 2: + super(mro[cur_mro_i + 2], self).__init__() + + def post_mousePressEvent(self, event: QGraphicsSceneMouseEvent): + """ + special "event" when mousePressEvent uses candidates + """ + pass diff --git a/src/lifeblood_viewer/graphics_items/scene_network_item.py b/src/lifeblood_viewer/graphics_items/scene_network_item.py new file mode 100644 index 00000000..dcdde8c1 --- /dev/null +++ b/src/lifeblood_viewer/graphics_items/scene_network_item.py @@ -0,0 +1,31 @@ +from .network_item import NetworkItemWithUI, NetworkItem +from .qextended_graphics_item import QGraphicsItemExtended +from ..graphics_scene_base import GraphicsSceneBase + +from PySide2.QtWidgets import QGraphicsItem + + +class SceneItemCommon(QGraphicsItemExtended): + def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value): + if change == QGraphicsItem.ItemSceneChange: # just before scene change + if self.scene() is not None and value is not None: + raise RuntimeError('changing scenes is not supported') + return super().itemChange(change, value) + + +class SceneNetworkItem(NetworkItem, SceneItemCommon): + def __init__(self, scene: GraphicsSceneBase, id: int): + super().__init__(id) + self.__scene = scene + + def graphics_scene(self) -> GraphicsSceneBase: + return self.__scene + + +class SceneNetworkItemWithUI(NetworkItemWithUI, SceneItemCommon): + def __init__(self, scene: GraphicsSceneBase, id: int): + super().__init__(id) + self.__scene = scene + + def graphics_scene(self) -> GraphicsSceneBase: + return self.__scene diff --git a/src/lifeblood_viewer/graphics_scene.py b/src/lifeblood_viewer/graphics_scene.py index 03429be7..9f49b83f 100644 --- a/src/lifeblood_viewer/graphics_scene.py +++ b/src/lifeblood_viewer/graphics_scene.py @@ -5,7 +5,8 @@ from types import MappingProxyType from .graphics_scene_container import GraphicsSceneWithNodesAndTasks -from .graphics_items import Task, Node, NodeConnection # +from .graphics_items import Task, Node, NodeConnection +from .graphics_items.qextended_graphics_item import QGraphicsItemExtended from .db_misc import sql_init_script_nodes from .long_op import LongOperation, LongOperationData, LongOperationProcessor from .connection_worker import SchedulerConnectionWorker @@ -219,13 +220,13 @@ def request_node_ui(self, node_id: int): def query_node_has_parameter(self, node_id: int, param_name: str, operation_data: Optional["LongOperationData"] = None): self._signal_node_has_parameter_requested.emit(node_id, param_name, operation_data) - def send_node_parameter_change(self, node_id: int, param: Parameter, operation_data: Optional["LongOperationData"] = None): + def request_node_parameter_change(self, node_id: int, param: Parameter, operation_data: Optional["LongOperationData"] = None): self._signal_node_parameter_change_requested.emit(node_id, param, operation_data) - def send_node_parameter_expression_change(self, node_id: int, param: Parameter, operation_data: Optional["LongOperationData"] = None): + def request_node_parameter_expression_change(self, node_id: int, param: Parameter, operation_data: Optional["LongOperationData"] = None): self._signal_node_parameter_expression_change_requested.emit(node_id, [param], operation_data) - def _send_node_parameters_change(self, node_id: int, params: Iterable[Parameter], operation_data: Optional["LongOperationData"] = None): + def request_node_parameters_change(self, node_id: int, params: Iterable[Parameter], operation_data: Optional["LongOperationData"] = None): self._signal_node_parameters_change_requested.emit(node_id, params, operation_data) def request_apply_node_settings(self, node_id: int, settings_name: str, operation_data: Optional["LongOperationData"] = None): @@ -246,19 +247,19 @@ def request_node_presets_update(self): def request_node_preset(self, packagename: str, presetname: str, operation_data: Optional["LongOperationData"] = None): self._signal_nodepreset_requested.emit(packagename, presetname, operation_data) - def _request_set_node_name(self, node_id: int, name: str, operation_data: Optional["LongOperationData"] = None): + def request_set_node_name(self, node_id: int, name: str, operation_data: Optional["LongOperationData"] = None): self._signal_set_node_name_requested.emit(node_id, name, operation_data) def request_node_connection_change(self, connection_id: int, outnode_id: Optional[int] = None, outname: Optional[str] = None, innode_id: Optional[int] = None, inname: Optional[str] = None): self._signal_change_node_connection_requested.emit(connection_id, outnode_id, outname, innode_id, inname) - def _request_node_connection_remove(self, connection_id: int, operation_data: Optional["LongOperationData"] = None): + def request_node_connection_remove(self, connection_id: int, operation_data: Optional["LongOperationData"] = None): self._signal_remove_node_connections_requested.emit([connection_id], operation_data) - def _request_node_connection_add(self, outnode_id: int, outname: str, innode_id: int, inname: str, operation_data: Optional["LongOperationData"] = None): + def request_node_connection_add(self, outnode_id: int, outname: str, innode_id: int, inname: str, operation_data: Optional["LongOperationData"] = None): self._signal_add_node_connection_requested.emit(outnode_id, outname, innode_id, inname, operation_data) - def _request_create_node(self, typename: str, nodename: str, pos: QPointF, operation_data: Optional["LongOperationData"] = None): + def request_create_node(self, typename: str, nodename: str, pos: QPointF, operation_data: Optional["LongOperationData"] = None): self._signal_create_node_requested.emit(typename, nodename, pos, operation_data) def request_remove_node(self, node_id: int, operation_data: Optional["LongOperationData"] = None): @@ -268,7 +269,7 @@ def request_remove_node(self, node_id: int, operation_data: Optional["LongOperat self.__node_snapshots[node_id] = UiNodeSnippetData.from_viewer_nodes([node]) self._signal_remove_nodes_requested.emit([node_id], operation_data) - def _request_remove_nodes(self, node_ids: List[int], operation_data: Optional["LongOperationData"] = None): + def request_remove_nodes(self, node_ids: List[int], operation_data: Optional["LongOperationData"] = None): if operation_data is None: for node_id in node_ids: node = self.get_node(node_id) @@ -436,11 +437,11 @@ def node_types(self) -> MappingProxyType[str, NodeTypeMetadata]: # async operations # - def create_node(self, typename: str, nodename: str, pos: QPointF, *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + def create_node(self, typename: str, nodename: str, pos: QPointF, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): op = CreateNodeOp(self, typename, nodename, pos) op.do(callback) - def delete_selected_nodes(self, *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + def delete_selected_nodes(self, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): nodes: List[Node] = [] for item in self.selectedItems(): if isinstance(item, Node): @@ -451,21 +452,21 @@ def delete_selected_nodes(self, *, callback: Optional[Callable[["UndoableOperati op = RemoveNodesOp(self, nodes) op.do(callback) - def add_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + def add_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): outnode = self.get_node(outnode_id) innode = self.get_node(innode_id) op = AddConnectionOp(self, outnode, outname, innode, inname) op.do(callback) - def cut_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + def cut_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): outnode = self.get_node(outnode_id) innode = self.get_node(innode_id) op = RemoveConnectionOp(self, outnode, outname, innode, inname) op.do(callback) - def cut_connection_by_id(self, con_id, *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + def cut_connection_by_id(self, con_id, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): con = self.get_node_connection(con_id) if con is None: return @@ -477,7 +478,7 @@ def cut_connection_by_id(self, con_id, *, callback: Optional[Callable[["Undoable def change_connection(self, from_outnode_id: int, from_outname: str, from_innode_id: int, from_inname: str, *, to_outnode_id: Optional[int] = None, to_outname: Optional[str] = None, to_innode_id: Optional[int] = None, to_inname: Optional[str] = None, - callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): # TODO: make proper ChangeConnectionOp from_outnode = self.get_node(from_outnode_id) from_innode = self.get_node(from_innode_id) @@ -494,7 +495,7 @@ def change_connection(self, from_outnode_id: int, from_outname: str, from_innode def change_connection_by_id(self, con_id, *, to_outnode_id: Optional[int] = None, to_outname: Optional[str] = None, to_innode_id: Optional[int] = None, to_inname: Optional[str] = None, - callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): con = self.get_node_connection(con_id) if con is None: return @@ -507,7 +508,7 @@ def change_connection_by_id(self, con_id, *, callback=callback) def change_node_parameter(self, node_id: int, item: Parameter, value: Any = ..., expression=..., - *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): """ :param node_id: @@ -522,7 +523,7 @@ def change_node_parameter(self, node_id: int, item: Parameter, value: Any = ..., op = ParameterChangeOp(self, self.get_node(node_id), item.name(), value, expression) op.do(callback) - def rename_node(self, node_id: int, new_name: str, *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + def rename_node(self, node_id: int, new_name: str, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): node = self.get_node(node_id) if node is None: logger.warning(f'cannot move node: node not found') @@ -1159,7 +1160,7 @@ def pasteop(longop): current_element += 1 if total_elements > 1: longop.set_op_status(current_element / (total_elements - 1), opname) - self._request_create_node(nodedata.type, nodedata.name, QPointF(*nodedata.pos) + pos - QPointF(*snippet.pos), LongOperationData(longop, None)) + self.request_create_node(nodedata.type, nodedata.name, QPointF(*nodedata.pos) + pos - QPointF(*snippet.pos), LongOperationData(longop, None)) # NOTE: there is currently no mechanism to ensure order of results when more than one things are requested # from the same operation. So we request and wait things one by one node_id, _, _ = yield @@ -1176,7 +1177,7 @@ def pasteop(longop): if param_data.expr is not None: proxy_param.set_expression(param_data.expr) proxy_params.append(proxy_param) - self._send_node_parameters_change(node_id, proxy_params, LongOperationData(longop, None)) + self.request_node_parameters_change(node_id, proxy_params, LongOperationData(longop, None)) yield for node_id in created_nodes: # selecting @@ -1192,8 +1193,8 @@ def pasteop(longop): if con_out is None or con_in is None: logger.warning('failed to create connection during snippet creation!') continue - self._request_node_connection_add(con_out, conndata.out_name, - con_in, conndata.in_name, LongOperationData(longop)) + self.request_node_connection_add(con_out, conndata.out_name, + con_in, conndata.in_name, LongOperationData(longop)) yield if total_elements > 1: @@ -1347,15 +1348,16 @@ def keyReleaseEvent(self, event: QKeyEvent) -> None: event.accept() # return super(QGraphicsImguiScene, self).keyReleaseEvent(event) - # this will also catch accumulated events that wires ignore to determine the losest wire + # this will also catch accumulated events that wires ignore to determine the closest wire def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: - event.wire_candidates = [] + item_event_candidates: List[Tuple[float, QGraphicsItemExtended]] = [] + event.item_event_candidates = item_event_candidates super(QGraphicsImguiScene, self).mousePressEvent(event) logger.debug(f'press mouse grabber={self.mouseGrabberItem()}') - if not event.isAccepted() and len(event.wire_candidates) > 0: - logger.debug('closest candidates: %s', ', '.join([str(x[0]) for x in event.wire_candidates])) - closest = min(event.wire_candidates, key=lambda x: x[0]) - closest[1].post_mousePressEvent(event) # this seem a bit unsafe, at least not typed statically enough + if not event.isAccepted() and len(event.item_event_candidates) > 0: + logger.debug('closest candidates: %s', ', '.join([str(x[0]) for x in event.item_event_candidates])) + closest = min(event.item_event_candidates, key=lambda x: x[0]) + closest[1].post_mousePressEvent(event) elif not event.isAccepted() and self.mouseGrabberItem() is None: logger.debug('probably started selecting') self.__selection_happening = True diff --git a/src/lifeblood_viewer/scene_data_controller.py b/src/lifeblood_viewer/scene_data_controller.py index 59504a08..e1371014 100644 --- a/src/lifeblood_viewer/scene_data_controller.py +++ b/src/lifeblood_viewer/scene_data_controller.py @@ -11,13 +11,13 @@ class SceneDataController: - def request_log(self, invocation_id: int, operation_data: Optional["LongOperationData"] = None): + def request_log(self, invocation_id: int, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def request_log_meta(self, task_id: int, operation_data: Optional["LongOperationData"] = None): + def request_log_meta(self, task_id: int, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def request_attributes(self, task_id: int, operation_data: Optional["LongOperationData"] = None): + def request_attributes(self, task_id: int, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() def request_invocation_job(self, task_id: int): @@ -26,25 +26,25 @@ def request_invocation_job(self, task_id: int): def request_node_ui(self, node_id: int): raise NotImplementedError() - def query_node_has_parameter(self, node_id: int, param_name: str, operation_data: Optional["LongOperationData"] = None): + def query_node_has_parameter(self, node_id: int, param_name: str, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def send_node_parameter_change(self, node_id: int, param: Parameter, operation_data: Optional["LongOperationData"] = None): + def request_node_parameter_change(self, node_id: int, param: Parameter, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def send_node_parameter_expression_change(self, node_id: int, param: Parameter, operation_data: Optional["LongOperationData"] = None): + def request_node_parameter_expression_change(self, node_id: int, param: Parameter, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def _send_node_parameters_change(self, node_id: int, params: Iterable[Parameter], operation_data: Optional["LongOperationData"] = None): + def request_node_parameters_change(self, node_id: int, params: Iterable[Parameter], operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def request_apply_node_settings(self, node_id: int, settings_name: str, operation_data: Optional["LongOperationData"] = None): + def request_apply_node_settings(self, node_id: int, settings_name: str, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def request_save_custom_settings(self, node_type_name: str, settings_name: str, settings: dict, operation_data: Optional["LongOperationData"] = None): + def request_save_custom_settings(self, node_type_name: str, settings_name: str, settings: dict, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def request_set_settings_default(self, node_type_name: str, settings_name: Optional[str], operation_data: Optional["LongOperationData"] = None): + def request_set_settings_default(self, node_type_name: str, settings_name: Optional[str], operation_data: Optional[LongOperationData] = None): raise NotImplementedError() def request_node_types_update(self): @@ -53,28 +53,28 @@ def request_node_types_update(self): def request_node_presets_update(self): raise NotImplementedError() - def request_node_preset(self, packagename: str, presetname: str, operation_data: Optional["LongOperationData"] = None): + def request_node_preset(self, packagename: str, presetname: str, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def _request_set_node_name(self, node_id: int, name: str, operation_data: Optional["LongOperationData"] = None): + def request_set_node_name(self, node_id: int, name: str, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() def request_node_connection_change(self, connection_id: int, outnode_id: Optional[int] = None, outname: Optional[str] = None, innode_id: Optional[int] = None, inname: Optional[str] = None): raise NotImplementedError() - def _request_node_connection_remove(self, connection_id: int, operation_data: Optional["LongOperationData"] = None): + def request_node_connection_remove(self, connection_id: int, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def _request_node_connection_add(self, outnode_id: int, outname: str, innode_id: int, inname: str, operation_data: Optional["LongOperationData"] = None): + def request_node_connection_add(self, outnode_id: int, outname: str, innode_id: int, inname: str, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def _request_create_node(self, typename: str, nodename: str, pos: QPointF, operation_data: Optional["LongOperationData"] = None): + def request_create_node(self, typename: str, nodename: str, pos: QPointF, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def request_remove_node(self, node_id: int, operation_data: Optional["LongOperationData"] = None): + def request_remove_node(self, node_id: int, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() - def _request_remove_nodes(self, node_ids: List[int], operation_data: Optional["LongOperationData"] = None): + def request_remove_nodes(self, node_ids: List[int], operation_data: Optional[LongOperationData] = None): raise NotImplementedError() def request_wipe_node(self, node_id: int): @@ -145,20 +145,23 @@ def request_workers_update(self): """ raise NotImplementedError() - def add_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + def add_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): raise NotImplementedError() - def cut_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + def cut_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): + raise NotImplementedError() + + def cut_connection_by_id(self, con_id, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): raise NotImplementedError() def change_connection_by_id(self, con_id, *, to_outnode_id: Optional[int] = None, to_outname: Optional[str] = None, to_innode_id: Optional[int] = None, to_inname: Optional[str] = None, - callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): raise NotImplementedError() def change_node_parameter(self, node_id: int, item: Parameter, value: Any = ..., expression=..., - *, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): """ :param node_id: diff --git a/src/lifeblood_viewer/scene_ops.py b/src/lifeblood_viewer/scene_ops.py index ea8c282e..f16e5091 100644 --- a/src/lifeblood_viewer/scene_ops.py +++ b/src/lifeblood_viewer/scene_ops.py @@ -61,7 +61,7 @@ def __init__(self, scene: "QGraphicsImguiScene", node_type: str, node_name: str, def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'create node') - self.__scene._request_create_node(self.__node_type, self.__node_name, self.__node_pos, LongOperationData(longop)) + self.__scene.request_create_node(self.__node_type, self.__node_name, self.__node_pos, LongOperationData(longop)) node_id, node_type, node_name = yield self.__node_sid = self.__scene._session_node_id_from_id(node_id) @@ -97,7 +97,7 @@ def _my_undo_longop(self, longop: LongOperation): print(node_ids) if not node_ids: return - self.__scene._request_remove_nodes(node_ids, LongOperationData(longop)) + self.__scene.request_remove_nodes(node_ids, LongOperationData(longop)) yield def __str__(self): @@ -119,7 +119,7 @@ def _my_do_longop(self, longop: LongOperation): if any(n is None for n in nodes): raise OperationError('some nodes disappeared before operation was done') self.__restoration_snippet = UiNodeSnippetData.from_viewer_nodes(nodes, include_dangling_connections=True) - self.__scene._request_remove_nodes(node_ids, LongOperationData(longop)) + self.__scene.request_remove_nodes(node_ids, LongOperationData(longop)) removed_ids, failed_ids_with_reasons = yield # now filter snippet to remove nodes that scheduler failed to remove #not_removed = set(node_ids) - set(removed_ids) @@ -172,7 +172,7 @@ def _my_do_longop(self, longop: LongOperation): if node is None: raise OperationError(f'node with session id {self.__node_sid} was not found') self.__old_name = node.node_name() - self.__scene._request_set_node_name(node_id, self.__new_name, LongOperationData(longop)) + self.__scene.request_set_node_name(node_id, self.__new_name, LongOperationData(longop)) yield def _my_undo_longop(self, longop: LongOperation): @@ -181,7 +181,7 @@ def _my_undo_longop(self, longop: LongOperation): node = self.__scene.get_node(node_id) if node is None: raise OperationError(f'node with session id {self.__node_sid} was not found') - self.__scene._request_set_node_name(node_id, self.__old_name, LongOperationData(longop)) + self.__scene.request_set_node_name(node_id, self.__old_name, LongOperationData(longop)) yield def __str__(self): @@ -236,7 +236,7 @@ def _my_do_longop(self, longop: LongOperation): or self.__scene.get_node(in_id) is None: logger.warning(f'could not perform op: nodes not found {out_id}, {in_id}') return - self.__scene._request_node_connection_add(out_id, self.__out_name, in_id, self.__in_name, LongOperationData(longop)) + self.__scene.request_node_connection_add(out_id, self.__out_name, in_id, self.__in_name, LongOperationData(longop)) yield def _my_undo_longop(self, longop: LongOperation): @@ -252,7 +252,7 @@ def _my_undo_longop(self, longop: LongOperation): if con is None: logger.warning('could not perform undo: added connection not found') return - self.__scene._request_node_connection_remove(con.get_id(), LongOperationData(longop)) + self.__scene.request_node_connection_remove(con.get_id(), LongOperationData(longop)) yield # TODO: check for errors def __str__(self): @@ -281,7 +281,7 @@ def _my_do_longop(self, longop: LongOperation): if con is None: logger.warning(f'could not perform op: added connection not found for {out_id}, {self.__out_name}, {in_id}, {self.__in_name}') return - self.__scene._request_node_connection_remove(con.get_id(), LongOperationData(longop)) + self.__scene.request_node_connection_remove(con.get_id(), LongOperationData(longop)) _, failed_ids_with_reasons = yield reasons = '\n'.join(f'- {reason}' for _, reason in failed_ids_with_reasons) op_result = OperationCompletionDetails(OperationCompletionStatus.FullSuccess) @@ -299,7 +299,7 @@ def _my_undo_longop(self, longop: LongOperation): or self.__scene.get_node(in_id) is None: logger.warning('could not perform undo: added connection not found') return - self.__scene._request_node_connection_add(out_id, self.__out_name, in_id, self.__in_name, LongOperationData(longop)) + self.__scene.request_node_connection_add(out_id, self.__out_name, in_id, self.__in_name, LongOperationData(longop)) yield def __str__(self): @@ -343,7 +343,7 @@ def _my_do_longop(self, longop: LongOperation): self._set_result(OperationCompletionDetails(OperationCompletionStatus.NotPerformed, 'parameter is read only')) return # TODO: currently possible errors on scheduler side are ignored, not good - self.__scene._send_node_parameters_change(node_id, [param], LongOperationData(longop)) + self.__scene.request_node_parameters_change(node_id, [param], LongOperationData(longop)) node = self.__scene.get_node(node_id) if node: node.item_updated(ui=True) @@ -357,12 +357,12 @@ def _my_undo_longop(self, longop: LongOperation): param.set_value(self.__old_value) if self.__old_expression is not ...: param.set_expression(self.__old_expression) - self.__scene._send_node_parameters_change(node_id, [param], LongOperationData(longop)) + self.__scene.request_node_parameters_change(node_id, [param], LongOperationData(longop)) yield # update node ui, just in case node = self.__scene.get_node(node_id) if node: - node.node.item_updated(ui=True) + node.item_updated(ui=True) def __str__(self): return f'Param Changed {self.__param_name} @ {self.__node_sid}' From e44e0f13eceb0119814ab1039c6095de6e947629 Mon Sep 17 00:00:00 2001 From: pedohorse <13556996+pedohorse@users.noreply.github.com> Date: Fri, 13 Sep 2024 15:22:59 +0200 Subject: [PATCH 4/6] graphics items refactor: cleaning up methods --- .../graphics_items/__init__.py | 2 +- .../graphics_items/graphics_items.py | 42 +++++++++---------- .../graphics_items/network_item.py | 12 ------ .../pretty_items/decorated_node.py | 4 +- .../pretty_items/drawable_node.py | 6 +-- .../graphics_items/scene_network_item.py | 6 +++ src/lifeblood_viewer/nodeeditor.py | 2 +- src/lifeblood_viewer/scene_ops.py | 7 ---- 8 files changed, 33 insertions(+), 48 deletions(-) diff --git a/src/lifeblood_viewer/graphics_items/__init__.py b/src/lifeblood_viewer/graphics_items/__init__.py index 6ab6160a..66155f77 100644 --- a/src/lifeblood_viewer/graphics_items/__init__.py +++ b/src/lifeblood_viewer/graphics_items/__init__.py @@ -1,4 +1,4 @@ # export inner classes -from .graphics_items import Node, Task, NodeConnection, SceneNetworkItemWithUI +from .graphics_items import Node, Task, NodeConnection from .network_item import NetworkItem, NetworkItemWithUI from .network_item_watchers import NetworkItemWatcher, WatchableNetworkItem, WatchableNetworkItemProxy diff --git a/src/lifeblood_viewer/graphics_items/graphics_items.py b/src/lifeblood_viewer/graphics_items/graphics_items.py index a8095b4b..fb1d334c 100644 --- a/src/lifeblood_viewer/graphics_items/graphics_items.py +++ b/src/lifeblood_viewer/graphics_items/graphics_items.py @@ -74,7 +74,7 @@ def set_name(self, new_name: str): if new_name == self.__name: return self.__name = new_name - self.item_updated(redraw=True, ui=True) + self.item_updated() def set_selected(self, selected: bool, *, unselect_others=False): scene: QGraphicsScene = self.graphics_scene() @@ -90,7 +90,7 @@ def update_nodeui(self, nodeui: NodeUi): def reanalyze_nodeui(self): Node._node_inputs_outputs_cached[self.__node_type] = (list(self.__nodeui.inputs_names()), list(self.__nodeui.outputs_names())) self.__inputs, self.__outputs = Node._node_inputs_outputs_cached[self.__node_type] - self.item_updated(redraw=True, ui=True) # cuz input count affects visualization in the graph + self.item_updated() def get_nodeui(self) -> Optional[NodeUi]: return self.__nodeui @@ -162,15 +162,13 @@ def add_task(self, task: "Task"): if task in self.__tasks: return logger.debug(f"adding task {task.get_id()} to node {self.get_id()}") - need_ui_update = self != task.node() + if task.node() and task.node() != self: task.node().remove_task(task) task._set_parent_node(self) self.__tasks.add(task) - if need_ui_update: - task.item_updated(redraw=False, ui=True) - self.item_updated(redraw=True, ui=False) # cuz node displays task number - we should redraw + self.item_updated() if len(self.item_watchers()) > 0: task.add_item_watcher(self) @@ -192,7 +190,7 @@ def remove_tasks(self, tasks_to_remove: Iterable["Task"]): # invalidate sorted cache self.__tasks_sorted_cached = None - self.item_updated(redraw=True, ui=False) # cuz node displays task number - we should redraw + self.item_updated() def remove_task(self, task_to_remove: "Task"): logger.debug(f"removing task {task_to_remove.get_id()} from node {self.get_id()}") @@ -204,7 +202,7 @@ def remove_task(self, task_to_remove: "Task"): self.__tasks_sorted_cached = None self.__tasks.remove(task_to_remove) - self.item_updated(redraw=True, ui=False) # cuz node displays task number - we should redraw + self.item_updated() def _sorted_tasks(self, order: TaskSortOrder) -> List["Task"]: if self.__tasks_sorted_cached is None: @@ -216,7 +214,7 @@ def _sorted_tasks(self, order: TaskSortOrder) -> List["Task"]: raise NotImplementedError(f'sort order {order} is not implemented') return self.__tasks_sorted_cached[order] - def tasks_iter(self, *, order: Optional[TaskSortOrder] = None): + def tasks_iter(self, *, order: Optional[TaskSortOrder] = None) -> Iterable["Task"]: if order is None: return (x for x in self.__tasks) return self._sorted_tasks(order) @@ -369,7 +367,7 @@ def set_name(self, name: str): if name == self.__raw_data.name: return self.__raw_data.name = name - self.item_updated(redraw=False, ui=True) + self.item_updated() def state(self) -> TaskState: return self.__raw_data.state @@ -389,7 +387,7 @@ def set_groups(self, groups: Set[str]): if self.__raw_data.groups == groups: return self.__raw_data.groups = groups - self.item_updated(redraw=False, ui=True) + self.item_updated() def attributes(self): return MappingProxyType(self.__ui_attributes) @@ -405,7 +403,7 @@ def set_state_details(self, state_details: Optional[str] = None): return self.__raw_data.state_details = state_details self.__state_details_cached = None - self.item_updated(redraw=False, ui=True) + self.item_updated() def set_state(self, state: Optional[TaskState], paused: Optional[bool]): if (state is None or state == self.__raw_data.state) and (paused is None or self.__raw_data.paused == paused): @@ -419,7 +417,7 @@ def set_state(self, state: Optional[TaskState], paused: Optional[bool]): self.__raw_data.paused = paused if self.__node: self.__node.task_state_changed(self) - self.item_updated(redraw=True, ui=True) + self.item_updated() def set_task_data(self, raw_data: TaskData): self.__state_details_cached = None @@ -427,7 +425,7 @@ def set_task_data(self, raw_data: TaskData): self.__raw_data = raw_data if state_changed and self.__node: self.__node.task_state_changed(self) - self.item_updated(redraw=True, ui=True) + self.item_updated() def apply_task_delta(self, task_delta: TaskDelta, get_node: Callable[[int], Node]): if task_delta.paused is not DataNotSet: @@ -466,18 +464,18 @@ def apply_task_delta(self, task_delta: TaskDelta, get_node: Callable[[int], Node self.__raw_data.parent_id = task_delta.parent_id if task_delta.state_details is not DataNotSet: self.set_state_details(task_delta.state_details) - self.item_updated(redraw=True, ui=True) + self.item_updated() def set_progress(self, progress: float): self.__raw_data.progress = progress # logger.debug('progress %d', progress) - self.item_updated(redraw=True, ui=True) + self.item_updated() def get_progress(self) -> Optional[float]: return self.__raw_data.progress if self.__raw_data else None - def item_updated(self, *, redraw: bool = False, ui: bool = False): - super().item_updated(redraw=redraw, ui=ui) + def item_updated(self): + super().item_updated() for watcher in self.item_watchers(): watcher.item_was_updated(self) @@ -519,7 +517,7 @@ def update_log(self, alllog: Dict[int, Dict[int, Union[IncompleteInvocationLogDa # clear cached inverted dict, it will be rebuilt on next access self.__reset_cached_invocation_data() - self.item_updated(redraw=False, ui=True) + self.item_updated() def remove_invocations_log(self, invocation_ids: List[int]): logger.debug('removing invocations for %s', invocation_ids) @@ -531,7 +529,7 @@ def remove_invocations_log(self, invocation_ids: List[int]): # clear cached inverted dict, it will be rebuilt on next access self.__reset_cached_invocation_data() - self.item_updated(redraw=False, ui=True) + self.item_updated() def invocations_total_time(self, only_last_per_node: bool = True) -> float: """ @@ -575,11 +573,11 @@ def invocation_logs(self) -> List[Tuple[int, int, Union[IncompleteInvocationLogD def update_attributes(self, attributes: dict): logger.debug('attrs updated with %s', attributes) self.__ui_attributes = attributes - self.item_updated(redraw=False, ui=True) + self.item_updated() def set_environment_attributes(self, env_attrs: Optional[EnvironmentResolverArguments]): self.__ui_env_res_attributes = env_attrs - self.item_updated(redraw=False, ui=True) + self.item_updated() def environment_attributes(self) -> Optional[EnvironmentResolverArguments]: return self.__ui_env_res_attributes diff --git a/src/lifeblood_viewer/graphics_items/network_item.py b/src/lifeblood_viewer/graphics_items/network_item.py index 9a870284..935d7cbf 100644 --- a/src/lifeblood_viewer/graphics_items/network_item.py +++ b/src/lifeblood_viewer/graphics_items/network_item.py @@ -9,18 +9,6 @@ def get_id(self): class NetworkItemWithUI(NetworkItem): - def item_updated(self, *, redraw: bool = False, ui: bool = False): - """ - should be called when item's state is changed - :param redraw: True if item itself redraw is needed - :param ui: True if item's parameter ui redraw is needed - """ - if redraw: - self.update() - if ui: - self.update() # currently contents and UI are drawn always together, so this will do - # but in future TODO: invalidate only UI layer - def draw_imgui_elements(self, drawing_widget): """ this should only be called from active opengl context! diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/decorated_node.py b/src/lifeblood_viewer/graphics_items/pretty_items/decorated_node.py index f5a9ac9e..b0a78c4f 100644 --- a/src/lifeblood_viewer/graphics_items/pretty_items/decorated_node.py +++ b/src/lifeblood_viewer/graphics_items/pretty_items/decorated_node.py @@ -15,7 +15,7 @@ def __init__(self, scene: GraphicsSceneBase, id: int, type: str, name: str, node for decorator in self.__decorators: decorator.setParentItem(self) - def item_updated(self, *, redraw: bool = False, ui: bool = False): - super().item_updated(redraw=redraw, ui=ui) + def item_updated(self): + super().item_updated() for decorator in self.__decorators: decorator.node_updated() diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/drawable_node.py b/src/lifeblood_viewer/graphics_items/pretty_items/drawable_node.py index 365549d8..1b11db1d 100644 --- a/src/lifeblood_viewer/graphics_items/pretty_items/drawable_node.py +++ b/src/lifeblood_viewer/graphics_items/pretty_items/drawable_node.py @@ -100,7 +100,7 @@ def set_expanded(self, expanded: bool): for i, task in enumerate(self.tasks()): self.__make_task_child_with_position(task, *self.get_task_pos(task, i), animate=True) - self.item_updated(redraw=True) + self.item_updated() def get_input_position(self, name: str = 'main', *, local: bool = False) -> QPointF: if not self.input_names(): @@ -236,7 +236,7 @@ def remove_task(self, task_to_remove: "Task"): self.__make_task_child_with_position(self.__visual_tasks[i], *self.get_task_pos(self.__visual_tasks[i], i), animate=True) self.__visual_tasks = self.__visual_tasks[:-1] assert task_to_remove not in self.__visual_tasks - self.item_updated(redraw=True, ui=False) # cuz node displays task number - we should redraw + self.item_updated() def _find_insert_index_for_task(self, task, prefer_back=False): if task.state() == TaskState.IN_PROGRESS and not prefer_back: @@ -349,7 +349,7 @@ def reanalyze_nodeui(self): self.__header_brush = QBrush(gradient) else: self.__header_brush = QBrush(QColor(*(x * 255 for x in css.main_color()), 192)) - self.item_updated(redraw=True, ui=True) # cuz input count affects visualization in the graph + self.item_updated() def prepareGeometryChange(self): super().prepareGeometryChange() diff --git a/src/lifeblood_viewer/graphics_items/scene_network_item.py b/src/lifeblood_viewer/graphics_items/scene_network_item.py index dcdde8c1..18ad620c 100644 --- a/src/lifeblood_viewer/graphics_items/scene_network_item.py +++ b/src/lifeblood_viewer/graphics_items/scene_network_item.py @@ -12,6 +12,12 @@ def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value): raise RuntimeError('changing scenes is not supported') return super().itemChange(change, value) + def item_updated(self): + """ + should be called when item's state is changed + """ + self.update() + class SceneNetworkItem(NetworkItem, SceneItemCommon): def __init__(self, scene: GraphicsSceneBase, id: int): diff --git a/src/lifeblood_viewer/nodeeditor.py b/src/lifeblood_viewer/nodeeditor.py index 2d143dd0..2180f89f 100644 --- a/src/lifeblood_viewer/nodeeditor.py +++ b/src/lifeblood_viewer/nodeeditor.py @@ -662,7 +662,7 @@ def show_task_menu(self, task: Task, *, pos: Optional[QPoint] = None): for state in TaskState: if state in (TaskState.GENERATING, TaskState.INVOKING, TaskState.IN_PROGRESS, TaskState.POST_GENERATING): continue - state_submenu.addAction(state.name).triggered.connect(lambda checked=False, x=task.get_id(), state=state: self.__scene.set_task_state([x], state)) + state_submenu.addAction(state.name).triggered.connect(lambda checked=False, x=task.get_id(), state_=state: self.__scene.set_task_state([x], state_)) if pos is None: pos = self.mapToGlobal(self.mapFromScene(task.scenePos())) diff --git a/src/lifeblood_viewer/scene_ops.py b/src/lifeblood_viewer/scene_ops.py index f16e5091..486e034c 100644 --- a/src/lifeblood_viewer/scene_ops.py +++ b/src/lifeblood_viewer/scene_ops.py @@ -344,9 +344,6 @@ def _my_do_longop(self, longop: LongOperation): return # TODO: currently possible errors on scheduler side are ignored, not good self.__scene.request_node_parameters_change(node_id, [param], LongOperationData(longop)) - node = self.__scene.get_node(node_id) - if node: - node.item_updated(ui=True) yield def _my_undo_longop(self, longop: LongOperation): @@ -359,10 +356,6 @@ def _my_undo_longop(self, longop: LongOperation): param.set_expression(self.__old_expression) self.__scene.request_node_parameters_change(node_id, [param], LongOperationData(longop)) yield - # update node ui, just in case - node = self.__scene.get_node(node_id) - if node: - node.item_updated(ui=True) def __str__(self): return f'Param Changed {self.__param_name} @ {self.__node_sid}' From b35a308e0c887045deec67a23b82b77233479028 Mon Sep 17 00:00:00 2001 From: pedohorse <13556996+pedohorse@users.noreply.github.com> Date: Sat, 14 Sep 2024 14:09:10 +0200 Subject: [PATCH 5/6] graphics scene refactor: break scene_ops<-->graphics_scene dep loop --- .../editor_scene_integration.py | 4 +- .../fancy_scene_item_factory.py | 3 +- .../graphics_items/__init__.py | 1 + .../graphics_items/graphics_items.py | 2 +- .../graphics_scene_base.py | 2 +- .../graphics_scene_container.py | 0 .../pretty_items/decorated_node.py | 2 +- .../pretty_items/drawable_node.py | 2 +- .../pretty_items/drawable_task.py | 2 +- .../pretty_items/fancy_items/scene_node.py | 2 +- .../fancy_items/scene_node_connection.py | 4 +- .../pretty_items/fancy_items/scene_task.py | 2 +- .../node_connection_create_preview.py | 2 +- .../graphics_items/scene_network_item.py | 2 +- src/lifeblood_viewer/graphics_scene.py | 1434 +---------------- .../graphics_scene_with_data_controller.py | 1419 ++++++++++++++++ src/lifeblood_viewer/lifeblood_viewer.py | 4 +- src/lifeblood_viewer/nodeeditor.py | 6 +- .../nodeeditor_overlays/overlay_base.py | 6 +- .../task_history_overlay.py | 4 +- src/lifeblood_viewer/scene_data_controller.py | 7 +- .../scene_item_factory_base.py | 3 +- src/lifeblood_viewer/scene_ops.py | 188 +-- src/lifeblood_viewer/ui_scene_elements.py | 6 +- src/lifeblood_viewer/undo_stack.py | 6 +- 25 files changed, 1566 insertions(+), 1547 deletions(-) rename src/lifeblood_viewer/{ => graphics_items}/graphics_scene_base.py (98%) rename src/lifeblood_viewer/{ => graphics_items}/graphics_scene_container.py (100%) create mode 100644 src/lifeblood_viewer/graphics_scene_with_data_controller.py diff --git a/src/lifeblood_viewer/editor_scene_integration.py b/src/lifeblood_viewer/editor_scene_integration.py index 3e420e0c..b670b15c 100644 --- a/src/lifeblood_viewer/editor_scene_integration.py +++ b/src/lifeblood_viewer/editor_scene_integration.py @@ -6,10 +6,10 @@ from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: - from .graphics_scene import QGraphicsImguiScene + from .graphics_scene_with_data_controller import QGraphicsImguiSceneWithDataController -def fetch_and_open_log_viewer(scene: "QGraphicsImguiScene", invoc_id: int, parent_widget: QWidget, *, update_interval: Optional[float] = None): +def fetch_and_open_log_viewer(scene: "QGraphicsImguiSceneWithDataController", invoc_id: int, parent_widget: QWidget, *, update_interval: Optional[float] = None): if update_interval is None: scene.fetch_log_run_callback(invoc_id, _open_log_viewer, parent_widget) else: diff --git a/src/lifeblood_viewer/fancy_scene_item_factory.py b/src/lifeblood_viewer/fancy_scene_item_factory.py index 6a9e8c73..2a8a4087 100644 --- a/src/lifeblood_viewer/fancy_scene_item_factory.py +++ b/src/lifeblood_viewer/fancy_scene_item_factory.py @@ -1,5 +1,4 @@ -from .graphics_items import Node, Task, NodeConnection -from .graphics_scene_base import GraphicsSceneBase +from .graphics_items import Node, Task, NodeConnection, GraphicsSceneBase from lifeblood.ui_protocol_data import TaskData from .scene_data_controller import SceneDataController diff --git a/src/lifeblood_viewer/graphics_items/__init__.py b/src/lifeblood_viewer/graphics_items/__init__.py index 66155f77..82094c1c 100644 --- a/src/lifeblood_viewer/graphics_items/__init__.py +++ b/src/lifeblood_viewer/graphics_items/__init__.py @@ -1,4 +1,5 @@ # export inner classes from .graphics_items import Node, Task, NodeConnection +from .graphics_scene_container import GraphicsSceneWithNodesAndTasks, GraphicsSceneBase from .network_item import NetworkItem, NetworkItemWithUI from .network_item_watchers import NetworkItemWatcher, WatchableNetworkItem, WatchableNetworkItemProxy diff --git a/src/lifeblood_viewer/graphics_items/graphics_items.py b/src/lifeblood_viewer/graphics_items/graphics_items.py index fb1d334c..d6734dbd 100644 --- a/src/lifeblood_viewer/graphics_items/graphics_items.py +++ b/src/lifeblood_viewer/graphics_items/graphics_items.py @@ -5,7 +5,7 @@ from .network_item_watchers import NetworkItemWatcher, WatchableNetworkItem, WatchableNetworkItemProxy from .scene_network_item import SceneNetworkItem, SceneNetworkItemWithUI -from ..graphics_scene_base import GraphicsSceneBase +from .graphics_scene_base import GraphicsSceneBase from lifeblood.uidata import NodeUi from lifeblood.ui_protocol_data import TaskData, TaskDelta, DataNotSet, IncompleteInvocationLogData, InvocationLogData diff --git a/src/lifeblood_viewer/graphics_scene_base.py b/src/lifeblood_viewer/graphics_items/graphics_scene_base.py similarity index 98% rename from src/lifeblood_viewer/graphics_scene_base.py rename to src/lifeblood_viewer/graphics_items/graphics_scene_base.py index 7b6d8c75..2bd4da51 100644 --- a/src/lifeblood_viewer/graphics_scene_base.py +++ b/src/lifeblood_viewer/graphics_items/graphics_scene_base.py @@ -1,4 +1,4 @@ -from .graphics_items.network_item import NetworkItem +from .network_item import NetworkItem from PySide2.QtWidgets import QGraphicsScene, QWidget diff --git a/src/lifeblood_viewer/graphics_scene_container.py b/src/lifeblood_viewer/graphics_items/graphics_scene_container.py similarity index 100% rename from src/lifeblood_viewer/graphics_scene_container.py rename to src/lifeblood_viewer/graphics_items/graphics_scene_container.py diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/decorated_node.py b/src/lifeblood_viewer/graphics_items/pretty_items/decorated_node.py index b0a78c4f..fd29321e 100644 --- a/src/lifeblood_viewer/graphics_items/pretty_items/decorated_node.py +++ b/src/lifeblood_viewer/graphics_items/pretty_items/decorated_node.py @@ -1,7 +1,7 @@ from .node_decorator_base import NodeDecorator, NodeDecoratorFactoryBase from .drawable_node import DrawableNode -from lifeblood_viewer.graphics_scene_base import GraphicsSceneBase +from ..graphics_scene_base import GraphicsSceneBase from typing import Iterable, List, Optional, Tuple diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/drawable_node.py b/src/lifeblood_viewer/graphics_items/pretty_items/drawable_node.py index 1b11db1d..a9b711af 100644 --- a/src/lifeblood_viewer/graphics_items/pretty_items/drawable_node.py +++ b/src/lifeblood_viewer/graphics_items/pretty_items/drawable_node.py @@ -1,10 +1,10 @@ from lifeblood import logging from lifeblood.enums import TaskState from ..graphics_items import Node, Task +from ..graphics_scene_base import GraphicsSceneBase from ..node_connection_snap_point import NodeConnSnapPoint from .drawable_task import DrawableTask -from lifeblood_viewer.graphics_scene_base import GraphicsSceneBase from PySide2.QtCore import Qt, QPointF, QRectF from PySide2.QtGui import QBrush, QColor, QLinearGradient, QPainter, QPainterPath, QPen diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/drawable_task.py b/src/lifeblood_viewer/graphics_items/pretty_items/drawable_task.py index 5efb3efe..2eb3184b 100644 --- a/src/lifeblood_viewer/graphics_items/pretty_items/drawable_task.py +++ b/src/lifeblood_viewer/graphics_items/pretty_items/drawable_task.py @@ -4,8 +4,8 @@ from lifeblood.ui_protocol_data import TaskData from .task_animation import TaskAnimation from ..graphics_items import Node, Task +from ..graphics_scene_container import GraphicsSceneWithNodesAndTasks -from lifeblood_viewer.graphics_scene_container import GraphicsSceneWithNodesAndTasks from PySide2.QtCore import QAbstractAnimation, Slot, QPointF, QRectF, QSizeF, QSequentialAnimationGroup from PySide2.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node.py b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node.py index f1879fbe..0ed260be 100644 --- a/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node.py +++ b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node.py @@ -7,11 +7,11 @@ from ...utils import call_later from ..decorated_node import DecoratedNode from ..node_connection_create_preview import NodeConnectionCreatePreview +from ...graphics_scene_container import GraphicsSceneWithNodesAndTasks from ...node_connection_snap_point import NodeConnSnapPoint from lifeblood_viewer.scene_data_controller import SceneDataController from lifeblood_viewer.code_editor.editor import StringParameterEditor -from lifeblood_viewer.graphics_scene_container import GraphicsSceneWithNodesAndTasks from lifeblood_viewer.graphics_scene_viewing_widget import GraphicsSceneViewingWidgetBase from ..node_decorator_base import NodeDecoratorFactoryBase diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node_connection.py b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node_connection.py index 4adf2603..a790d852 100644 --- a/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node_connection.py +++ b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_node_connection.py @@ -1,12 +1,12 @@ from math import sqrt from lifeblood import logging -from lifeblood_viewer.graphics_items import Node, NodeConnection +from ...graphics_items import Node, NodeConnection from ...utils import call_later, length2 from ..node_connection_create_preview import NodeConnectionCreatePreview +from ...graphics_scene_container import GraphicsSceneWithNodesAndTasks from ...node_connection_snap_point import NodeConnSnapPoint from lifeblood_viewer.scene_data_controller import SceneDataController -from lifeblood_viewer.graphics_scene_container import GraphicsSceneWithNodesAndTasks from lifeblood_viewer.graphics_scene_viewing_widget import GraphicsSceneViewingWidgetBase from PySide2.QtCore import Qt, Slot, QPointF, QRectF diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_task.py b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_task.py index 86152631..00fe357f 100644 --- a/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_task.py +++ b/src/lifeblood_viewer/graphics_items/pretty_items/fancy_items/scene_task.py @@ -6,13 +6,13 @@ from .scene_task_preview import SceneTaskPreview from ..drawable_task import DrawableTask from ...graphics_items import Node +from ...graphics_scene_container import GraphicsSceneWithNodesAndTasks from ...network_item_watchers import NetworkItemWatcher from ...utils import call_later from lifeblood_viewer.editor_scene_integration import fetch_and_open_log_viewer from lifeblood_viewer.scene_data_controller import SceneDataController -from lifeblood_viewer.graphics_scene_container import GraphicsSceneWithNodesAndTasks from lifeblood_viewer.graphics_scene_viewing_widget import GraphicsSceneViewingWidgetBase from PySide2.QtCore import Qt, QPointF diff --git a/src/lifeblood_viewer/graphics_items/pretty_items/node_connection_create_preview.py b/src/lifeblood_viewer/graphics_items/pretty_items/node_connection_create_preview.py index 992a5a13..e200810a 100644 --- a/src/lifeblood_viewer/graphics_items/pretty_items/node_connection_create_preview.py +++ b/src/lifeblood_viewer/graphics_items/pretty_items/node_connection_create_preview.py @@ -1,4 +1,4 @@ -from lifeblood_viewer.graphics_items import Node +from ..graphics_items import Node from ..utils import length2 from ..node_connection_snap_point import NodeConnSnapPoint diff --git a/src/lifeblood_viewer/graphics_items/scene_network_item.py b/src/lifeblood_viewer/graphics_items/scene_network_item.py index 18ad620c..061339a5 100644 --- a/src/lifeblood_viewer/graphics_items/scene_network_item.py +++ b/src/lifeblood_viewer/graphics_items/scene_network_item.py @@ -1,6 +1,6 @@ from .network_item import NetworkItemWithUI, NetworkItem from .qextended_graphics_item import QGraphicsItemExtended -from ..graphics_scene_base import GraphicsSceneBase +from .graphics_scene_base import GraphicsSceneBase from PySide2.QtWidgets import QGraphicsItem diff --git a/src/lifeblood_viewer/graphics_scene.py b/src/lifeblood_viewer/graphics_scene.py index 9f49b83f..97aa7eac 100644 --- a/src/lifeblood_viewer/graphics_scene.py +++ b/src/lifeblood_viewer/graphics_scene.py @@ -1,568 +1,30 @@ -import sqlite3 - -import grandalf.graphs -import grandalf.layouts - -from types import MappingProxyType -from .graphics_scene_container import GraphicsSceneWithNodesAndTasks -from .graphics_items import Task, Node, NodeConnection -from .graphics_items.qextended_graphics_item import QGraphicsItemExtended -from .db_misc import sql_init_script_nodes -from .long_op import LongOperation, LongOperationData, LongOperationProcessor -from .connection_worker import SchedulerConnectionWorker -from .undo_stack import UndoStack, UndoableOperation, OperationCompletionDetails -from .ui_snippets import UiNodeSnippetData -from .scene_item_factory_base import SceneItemFactoryBase -from .scene_data_controller import SceneDataController -from .scene_ops import ( - CompoundAsyncSceneOperation, - CreateNodeOp, CreateNodesOp, - RemoveNodesOp, RenameNodeOp, - MoveNodesOp, - AddConnectionOp, RemoveConnectionOp, - ParameterChangeOp) - -from lifeblood.misc import timeit, performance_measurer -from lifeblood.uidata import NodeUi, Parameter -from lifeblood.ui_protocol_data import UiData, TaskBatchData, NodeGraphStructureData, TaskDelta, DataNotSet, IncompleteInvocationLogData, InvocationLogData -from lifeblood.enums import TaskState, TaskGroupArchivedState from lifeblood import logging -from lifeblood.node_type_metadata import NodeTypeMetadata -from lifeblood.taskspawn import NewTask -from lifeblood.invocationjob import InvocationJob -from lifeblood.snippets import NodeSnippetData, NodeSnippetDataPlaceholder -from lifeblood.environment_resolver import EnvironmentResolverArguments -from lifeblood.ui_events import TaskEvent, TasksRemoved, TasksUpdated, TasksChanged, TaskFullState from lifeblood.config import get_config +from .graphics_items.graphics_scene_container import GraphicsSceneWithNodesAndTasks +from .long_op import LongOperation, LongOperationData, LongOperationProcessor +from .undo_stack import UndoStack, UndoableOperation, OperationCompletionDetails +from PySide2.QtCore import Slot -from PySide2.QtWidgets import * -from PySide2.QtCore import Slot, Signal, QThread, QRectF, QPointF -from PySide2.QtGui import QKeyEvent - -from typing import Callable, Generator, Optional, List, Mapping, Tuple, Dict, Set, Iterable, Union, Any, Sequence +from typing import Callable, Dict, Generator, List, Optional, Tuple logger = logging.get_logger('viewer') -class QGraphicsImguiScene(GraphicsSceneWithNodesAndTasks, LongOperationProcessor, SceneDataController): - # these are private signals to invoke shit on worker in another thread. QMetaObject's invokemethod is broken in pyside2 - _signal_log_has_been_requested = Signal(int, object) - _signal_log_meta_has_been_requested = Signal(int, object) - _signal_node_ui_has_been_requested = Signal(int) - _signal_task_ui_attributes_has_been_requested = Signal(int, object) - _signal_task_invocation_job_requested = Signal(int) - _signal_node_has_parameter_requested = Signal(int, str, object) - _signal_node_parameter_change_requested = Signal(int, object, object) - _signal_node_parameter_expression_change_requested = Signal(int, object, object) - _signal_node_parameters_change_requested = Signal(int, object, object) - _signal_node_apply_settings_requested = Signal(int, str, object) - _signal_node_save_custom_settings_requested = Signal(str, str, object, object) - _signal_node_set_settings_default_requested = Signal(str, object, object) - _signal_nodetypes_update_requested = Signal() - _signal_nodepresets_update_requested = Signal() - _signal_set_node_name_requested = Signal(int, str, object) - _signal_nodepreset_requested = Signal(str, str, object) - _signal_create_node_requested = Signal(str, str, QPointF, object) - _signal_remove_nodes_requested = Signal(list, object) - _signal_wipe_node_requested = Signal(int) - _signal_change_node_connection_requested = Signal(int, object, object, object, object) - _signal_remove_node_connections_requested = Signal(list, object) - _signal_add_node_connection_requested = Signal(int, str, int, str, object) - _signal_set_task_group_filter = Signal(set) - _signal_set_task_state = Signal(list, TaskState) - _signal_set_tasks_paused = Signal(object, bool) # object is Union[List[int], int, str] - _signal_set_task_group_state_requested = Signal(str, TaskGroupArchivedState) - _signal_set_task_node_requested = Signal(int, int) - _signal_set_task_name_requested = Signal(int, str) - _signal_set_task_groups_requested = Signal(int, set) - _signal_update_task_attributes_requested = Signal(int, dict, set) - _signal_cancel_task_requested = Signal(int) - _signal_add_task_requested = Signal(NewTask) - _signal_duplicate_nodes_requested = Signal(dict, QPointF) - _signal_set_skip_dead = Signal(bool) - _signal_set_skip_archived_groups = Signal(bool) - _signal_set_environment_resolver_arguments = Signal(int, EnvironmentResolverArguments) - _signal_unset_environment_resolver_arguments = Signal(int) - # - _signal_poke_graph_and_tasks_update = Signal() - _signal_poke_task_groups_update = Signal() - _signal_poke_workers_update = Signal() - # - # - nodetypes_updated = Signal(dict) # TODO: separate worker-oriented "private" signals for readability - nodepresets_updated = Signal(dict) - nodepreset_received = Signal(str, str, NodeSnippetData) - task_invocation_job_fetched = Signal(int, InvocationJob) - unhandled_error_happened = Signal(str) - - # - operation_started = Signal(int) # operation id - operation_progress_updated = Signal(int, str, float) # operation id, name, progress 0.0 - 1.0 - operation_finished = Signal(int) # operation id - - def __init__(self, scene_item_factory: SceneItemFactoryBase, db_path: str = None, worker: Optional["SchedulerConnectionWorker"] = None, parent=None): - super(QGraphicsImguiScene, self).__init__(parent=parent) - # to debug fuching bsp # self.setItemIndexMethod(QGraphicsScene.NoIndex) +class GraphicsScene(GraphicsSceneWithNodesAndTasks, LongOperationProcessor): + def __init__(self, parent=None): + super().__init__(parent=parent) self.__config = get_config('viewer') - self.__scene_item_factory: SceneItemFactoryBase = scene_item_factory - self.__db_path = db_path - self.__nodes_table_name = None - self.__cached_nodetypes: Dict[str, NodeTypeMetadata] = {} - self.__cached_nodepresets: Dict[str, Dict[str, Union[NodeSnippetData, NodeSnippetDataPlaceholder]]] = {} - self.__task_group_filter = None - self.__db_uid: Optional[int] = None # this is unique id of the scheduler's db. we use this to determine where to save node positions locally, not to mix and collide - - self.__tasks_to_try_reparent_during_node_update = {} - - self.__undo_stack: UndoStack = UndoStack() - self.reset_undo_stack() - - self.__node_snapshots = {} # for undo/redo - self.__selection_happening = False - - if worker is None: - self.__ui_connection_thread = QThread(self) # SchedulerConnectionThread(self) - self.__ui_connection_worker = SchedulerConnectionWorker() - self.__ui_connection_worker.moveToThread(self.__ui_connection_thread) - - self.__ui_connection_thread.started.connect(self.__ui_connection_worker.start) - self.__ui_connection_thread.finished.connect(self.__ui_connection_worker.finish) - else: - self.__ui_connection_thread = None - self.__ui_connection_worker = worker self.__long_operations: Dict[int, Tuple[LongOperation, Optional[str]]] = {} self.__long_op_queues: Dict[str, List[Callable[["LongOperation"], Generator]]] = {} - self.__ui_connection_worker.graph_full_update.connect(self.graph_full_update) - self.__ui_connection_worker.tasks_full_update.connect(self.tasks_full_update) - self.__ui_connection_worker.tasks_events_arrived.connect(self.tasks_process_events) - self.__ui_connection_worker.db_uid_update.connect(self.db_uid_update) - self.__ui_connection_worker.log_fetched.connect(self.log_fetched) - self.__ui_connection_worker.nodeui_fetched.connect(self.nodeui_fetched) - self.__ui_connection_worker.task_attribs_fetched.connect(self._task_attribs_fetched) - self.__ui_connection_worker.task_invocation_job_fetched.connect(self._task_invocation_job_fetched) - self.__ui_connection_worker.nodetypes_fetched.connect(self._nodetypes_fetched) - self.__ui_connection_worker.nodepresets_fetched.connect(self._nodepresets_fetched) - self.__ui_connection_worker.nodepreset_fetched.connect(self._nodepreset_fetched) - self.__ui_connection_worker.node_has_parameter.connect(self._node_has_parameter) - self.__ui_connection_worker.node_parameter_changed.connect(self._node_parameter_changed) - self.__ui_connection_worker.node_parameters_changed.connect(self._node_parameters_changed) - self.__ui_connection_worker.node_parameter_expression_changed.connect(self._node_parameter_expression_changed) - self.__ui_connection_worker.node_settings_applied.connect(self._node_settings_applied) - self.__ui_connection_worker.node_custom_settings_saved.connect(self._node_custom_settings_saved) - self.__ui_connection_worker.node_default_settings_set.connect(self._node_default_settings_set) - self.__ui_connection_worker.node_created.connect(self._node_created) - self.__ui_connection_worker.nodes_removed.connect(self._nodes_removed) - self.__ui_connection_worker.node_renamed.connect(self._node_renamed) - self.__ui_connection_worker.nodes_copied.connect(self._nodes_duplicated) - self.__ui_connection_worker.node_connections_removed.connect(self._node_connections_removed) - self.__ui_connection_worker.node_connections_added.connect(self._node_connections_added) - self.__ui_connection_worker.node_task_set.connect(self._node_task_set) - - self._signal_log_has_been_requested.connect(self.__ui_connection_worker.get_log) - self._signal_log_meta_has_been_requested.connect(self.__ui_connection_worker.get_invocation_metadata) - self._signal_node_ui_has_been_requested.connect(self.__ui_connection_worker.get_nodeui) - self._signal_task_ui_attributes_has_been_requested.connect(self.__ui_connection_worker.get_task_attribs) - self._signal_node_has_parameter_requested.connect(self.__ui_connection_worker.send_node_has_parameter) - self._signal_node_parameter_change_requested.connect(self.__ui_connection_worker.send_node_parameter_change) - self._signal_node_parameter_expression_change_requested.connect(self.__ui_connection_worker.send_node_parameters_change) - self._signal_node_parameters_change_requested.connect(self.__ui_connection_worker.send_node_parameters_change) - self._signal_node_apply_settings_requested.connect(self.__ui_connection_worker.apply_node_settings) - self._signal_node_save_custom_settings_requested.connect(self.__ui_connection_worker.node_save_custom_settings) - self._signal_node_set_settings_default_requested.connect(self.__ui_connection_worker.node_set_settings_default) - self._signal_nodetypes_update_requested.connect(self.__ui_connection_worker.get_nodetypes) - self._signal_nodepresets_update_requested.connect(self.__ui_connection_worker.get_nodepresets) - self._signal_nodepreset_requested.connect(self.__ui_connection_worker.get_nodepreset) - self._signal_set_node_name_requested.connect(self.__ui_connection_worker.set_node_name) - self._signal_create_node_requested.connect(self.__ui_connection_worker.create_node) - self._signal_remove_nodes_requested.connect(self.__ui_connection_worker.remove_nodes) - self._signal_wipe_node_requested.connect(self.__ui_connection_worker.wipe_node) - self._signal_duplicate_nodes_requested.connect(self.__ui_connection_worker.duplicate_nodes) - self._signal_change_node_connection_requested.connect(self.__ui_connection_worker.change_node_connection) - self._signal_remove_node_connections_requested.connect(self.__ui_connection_worker.remove_node_connections) - self._signal_add_node_connection_requested.connect(self.__ui_connection_worker.add_node_connection) - self._signal_set_task_state.connect(self.__ui_connection_worker.set_task_state) - self._signal_set_tasks_paused.connect(self.__ui_connection_worker.set_tasks_paused) - self._signal_set_task_group_state_requested.connect(self.__ui_connection_worker.set_task_group_archived_state) - self._signal_set_task_group_filter.connect(self.__ui_connection_worker.set_task_group_filter) - self._signal_set_task_node_requested.connect(self.__ui_connection_worker.set_task_node) - self._signal_set_task_name_requested.connect(self.__ui_connection_worker.set_task_name) - self._signal_set_task_groups_requested.connect(self.__ui_connection_worker.set_task_groups) - self._signal_update_task_attributes_requested.connect(self.__ui_connection_worker.update_task_attributes) - self._signal_cancel_task_requested.connect(self.__ui_connection_worker.cancel_task) - self._signal_add_task_requested.connect(self.__ui_connection_worker.add_task) - self._signal_task_invocation_job_requested.connect(self.__ui_connection_worker.get_task_invocation_job) - self._signal_set_skip_dead.connect(self.__ui_connection_worker.set_skip_dead) - self._signal_set_skip_archived_groups.connect(self.__ui_connection_worker.set_skip_archived_groups) - self._signal_set_environment_resolver_arguments.connect(self.__ui_connection_worker.set_environment_resolver_arguments) - self._signal_unset_environment_resolver_arguments.connect(self.__ui_connection_worker.unset_environment_resolver_arguments) - # - self._signal_poke_graph_and_tasks_update.connect(self.__ui_connection_worker.poke_graph_and_tasks_update) - self._signal_poke_task_groups_update.connect(self.__ui_connection_worker.poke_task_groups_update) - self._signal_poke_workers_update.connect(self.__ui_connection_worker.poke_workers_update) - - def request_log(self, invocation_id: int, operation_data: Optional["LongOperationData"] = None): - self._signal_log_has_been_requested.emit(invocation_id, operation_data) - - def request_log_meta(self, task_id: int, operation_data: Optional["LongOperationData"] = None): - self._signal_log_meta_has_been_requested.emit(task_id, operation_data) - - def request_attributes(self, task_id: int, operation_data: Optional["LongOperationData"] = None): - self._signal_task_ui_attributes_has_been_requested.emit(task_id, operation_data) - - def request_invocation_job(self, task_id: int): - self._signal_task_invocation_job_requested.emit(task_id) - - def request_node_ui(self, node_id: int): - self._signal_node_ui_has_been_requested.emit(node_id) - - def query_node_has_parameter(self, node_id: int, param_name: str, operation_data: Optional["LongOperationData"] = None): - self._signal_node_has_parameter_requested.emit(node_id, param_name, operation_data) - - def request_node_parameter_change(self, node_id: int, param: Parameter, operation_data: Optional["LongOperationData"] = None): - self._signal_node_parameter_change_requested.emit(node_id, param, operation_data) - - def request_node_parameter_expression_change(self, node_id: int, param: Parameter, operation_data: Optional["LongOperationData"] = None): - self._signal_node_parameter_expression_change_requested.emit(node_id, [param], operation_data) - - def request_node_parameters_change(self, node_id: int, params: Iterable[Parameter], operation_data: Optional["LongOperationData"] = None): - self._signal_node_parameters_change_requested.emit(node_id, params, operation_data) - - def request_apply_node_settings(self, node_id: int, settings_name: str, operation_data: Optional["LongOperationData"] = None): - self._signal_node_apply_settings_requested.emit(node_id, settings_name, operation_data) - - def request_save_custom_settings(self, node_type_name: str, settings_name: str, settings: dict, operation_data: Optional["LongOperationData"] = None): - self._signal_node_save_custom_settings_requested.emit(node_type_name, settings_name, settings, operation_data) - - def request_set_settings_default(self, node_type_name: str, settings_name: Optional[str], operation_data: Optional["LongOperationData"] = None): - self._signal_node_set_settings_default_requested.emit(node_type_name, settings_name, operation_data) - - def request_node_types_update(self): - self._signal_nodetypes_update_requested.emit() - - def request_node_presets_update(self): - self._signal_nodepresets_update_requested.emit() - - def request_node_preset(self, packagename: str, presetname: str, operation_data: Optional["LongOperationData"] = None): - self._signal_nodepreset_requested.emit(packagename, presetname, operation_data) - - def request_set_node_name(self, node_id: int, name: str, operation_data: Optional["LongOperationData"] = None): - self._signal_set_node_name_requested.emit(node_id, name, operation_data) - - def request_node_connection_change(self, connection_id: int, outnode_id: Optional[int] = None, outname: Optional[str] = None, innode_id: Optional[int] = None, inname: Optional[str] = None): - self._signal_change_node_connection_requested.emit(connection_id, outnode_id, outname, innode_id, inname) - - def request_node_connection_remove(self, connection_id: int, operation_data: Optional["LongOperationData"] = None): - self._signal_remove_node_connections_requested.emit([connection_id], operation_data) - - def request_node_connection_add(self, outnode_id: int, outname: str, innode_id: int, inname: str, operation_data: Optional["LongOperationData"] = None): - self._signal_add_node_connection_requested.emit(outnode_id, outname, innode_id, inname, operation_data) - - def request_create_node(self, typename: str, nodename: str, pos: QPointF, operation_data: Optional["LongOperationData"] = None): - self._signal_create_node_requested.emit(typename, nodename, pos, operation_data) - - def request_remove_node(self, node_id: int, operation_data: Optional["LongOperationData"] = None): - if operation_data is None: - node = self.get_node(node_id) - if node is not None: - self.__node_snapshots[node_id] = UiNodeSnippetData.from_viewer_nodes([node]) - self._signal_remove_nodes_requested.emit([node_id], operation_data) - - def request_remove_nodes(self, node_ids: List[int], operation_data: Optional["LongOperationData"] = None): - if operation_data is None: - for node_id in node_ids: - node = self.get_node(node_id) - if node is not None: - self.__node_snapshots[node_id] = UiNodeSnippetData.from_viewer_nodes([node]) - self._signal_remove_nodes_requested.emit(node_ids, operation_data) - - def request_wipe_node(self, node_id: int): - self._signal_wipe_node_requested.emit(node_id) - - def request_duplicate_nodes(self, node_ids: List[int], shift: QPointF): - self._signal_duplicate_nodes_requested.emit(node_ids, shift) - - def set_task_group_filter(self, groups): - self._signal_set_task_group_filter.emit(groups) - - def set_task_state(self, task_ids: List[int], state: TaskState): - self._signal_set_task_state.emit(task_ids, state) - - def set_tasks_paused(self, task_ids_or_groups: List[Union[int, str]], paused: bool): - if all(isinstance(x, int) for x in task_ids_or_groups): - self._signal_set_tasks_paused.emit(task_ids_or_groups, paused) - else: - for tid_or_group in task_ids_or_groups: - self._signal_set_tasks_paused.emit(tid_or_group, paused) - - def set_task_group_archived_state(self, group_names: List[str], state: TaskGroupArchivedState): - for group_name in group_names: - self._signal_set_task_group_state_requested.emit(group_name, state) - - def request_task_cancel(self, task_id: int): - self._signal_cancel_task_requested.emit(task_id) - - def request_set_task_node(self, task_id: int, node_id: int): - self._signal_set_task_node_requested.emit(task_id, node_id) - - def request_add_task(self, new_task: NewTask): - self._signal_add_task_requested.emit(new_task) - - def request_rename_task(self, task_id: int, new_name: str): - self._signal_set_task_name_requested.emit(task_id, new_name) - - def request_set_task_groups(self, task_id: int, new_groups: Set[str]): - self._signal_set_task_groups_requested.emit(task_id, new_groups) - - def request_update_task_attributes(self, task_id: int, attribs_to_update: dict, attribs_to_delete: Set[str]): - self._signal_update_task_attributes_requested.emit(task_id, attribs_to_update, attribs_to_delete) - - def set_skip_dead(self, do_skip: bool) -> None: - self._signal_set_skip_dead.emit(do_skip) - - def set_skip_archived_groups(self, do_skip: bool) -> None: - self._signal_set_skip_archived_groups.emit(do_skip) - - def request_set_environment_resolver_arguments(self, task_id, env_args): - self._signal_set_environment_resolver_arguments.emit(task_id, env_args) - - def request_unset_environment_resolver_arguments(self, task_id): - self._signal_unset_environment_resolver_arguments.emit(task_id) - - # - - def request_graph_and_tasks_update(self): - """ - send a request to the scheduler to update node graph and tasks state immediately - """ - self._signal_poke_graph_and_tasks_update.emit() - - def request_task_groups_update(self): - """ - send a request to the scheduler to update task groups state immediately - """ - self._signal_poke_task_groups_update.emit() - - def request_workers_update(self): - """ - send a request to the scheduler to update workers state immediately - """ - self._signal_poke_workers_update.emit() - - # - # Higher-level request functions: - - def regenerate_all_ready_tasks_for_node(self, node_id: int): - """ - all currently displayed tasks that are in states BEFORE invoking/in-progress, will be set to WAITING - """ - self._change_all_task_states_for_node(node_id, (TaskState.READY, TaskState.WAITING_BLOCKED), TaskState.WAITING) - - def retry_all_error_tasks_for_node(self, node_id: int): - """ - all currently displayed task that are in ERROR state will be reset to WAITING - """ - self._change_all_task_states_for_node(node_id, (TaskState.ERROR,), TaskState.WAITING) - - def _change_all_task_states_for_node(self, node_id: int, from_states: Tuple[TaskState, ...], to_state: TaskState): - node = self.get_node(node_id) - self.set_task_state( - [x.get_id() for x in node.tasks_iter() if x.state() in from_states], - to_state - ) - - # - # - # + self.__undo_stack: UndoStack = UndoStack() + self.reset_undo_stack() def reset_undo_stack(self): self.__undo_stack = UndoStack(max_undos=self.__config.get_option_noasync('viewer.max_undo_history_size', 100)) - # - # - - # - # - - def skip_dead(self) -> bool: - return self.__ui_connection_worker.skip_dead() # should be fine and thread-safe in eyes of python - - def skip_archived_groups(self) -> bool: - return self.__ui_connection_worker.skip_archived_groups() # should be fine and thread-safe in eyes of python - - # - - def _nodes_were_moved(self, nodes_datas: Sequence[Tuple[Node, QPointF]]): - """ - item needs to notify the scene that move operation has happened, - scene needs to create an undo entry for that - """ - - op = MoveNodesOp(self, - ((node, node.pos(), old_pos) for node, old_pos in nodes_datas) - ) - op.do() - - def node_position(self, node_id: int): - if self.__db_path is not None: - if self.__nodes_table_name is None: - raise RuntimeError('node positions requested before db uid set') - with sqlite3.connect(self.__db_path) as con: - con.row_factory = sqlite3.Row - cur = con.execute(f'SELECT * FROM "{self.__nodes_table_name}" WHERE "id" = ?', (node_id,)) - row = cur.fetchone() - if row is not None: - return row['posx'], row['posy'] - - raise ValueError(f'node id {node_id} has no stored position') - - def set_node_position(self, node_id: int, pos: Union[Tuple[float, float], QPointF]): - if isinstance(pos, QPointF): - pos = pos.toTuple() - if self.__db_path is not None: - if self.__nodes_table_name is None: - raise RuntimeError('node positions requested before db uid set') - with sqlite3.connect(self.__db_path) as con: - con.row_factory = sqlite3.Row - cur = con.execute(f'INSERT INTO "{self.__nodes_table_name}" ("id", "posx", "posy") VALUES (?, ?, ?) ON CONFLICT("id") DO UPDATE SET posx = ?, posy = ?', (node_id, *pos, *pos)) - row = cur.fetchone() - if row is not None: - return row['posx'], row['posy'] - - def node_types(self) -> MappingProxyType[str, NodeTypeMetadata]: - return MappingProxyType(self.__cached_nodetypes) - - # - # async operations - # - - def create_node(self, typename: str, nodename: str, pos: QPointF, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): - op = CreateNodeOp(self, typename, nodename, pos) - op.do(callback) - - def delete_selected_nodes(self, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): - nodes: List[Node] = [] - for item in self.selectedItems(): - if isinstance(item, Node): - nodes.append(item) - if not nodes: - return - - op = RemoveNodesOp(self, nodes) - op.do(callback) - - def add_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): - outnode = self.get_node(outnode_id) - innode = self.get_node(innode_id) - - op = AddConnectionOp(self, outnode, outname, innode, inname) - op.do(callback) - - def cut_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): - outnode = self.get_node(outnode_id) - innode = self.get_node(innode_id) - - op = RemoveConnectionOp(self, outnode, outname, innode, inname) - op.do(callback) - - def cut_connection_by_id(self, con_id, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): - con = self.get_node_connection(con_id) - if con is None: - return - cin = con.input() - cout = con.output() - - return self.cut_connection(cout[0].get_id(), cout[1], cin[0].get_id(), cin[1], callback=callback) - - def change_connection(self, from_outnode_id: int, from_outname: str, from_innode_id: int, from_inname: str, *, - to_outnode_id: Optional[int] = None, to_outname: Optional[str] = None, - to_innode_id: Optional[int] = None, to_inname: Optional[str] = None, - callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): - # TODO: make proper ChangeConnectionOp - from_outnode = self.get_node(from_outnode_id) - from_innode = self.get_node(from_innode_id) - to_outnode = self.get_node(to_outnode_id) if to_outnode_id is not None else None - to_innode = self.get_node(to_innode_id) if to_innode_id is not None else None - - op1 = RemoveConnectionOp(self, from_outnode, from_outname, from_innode, from_inname) - op2 = AddConnectionOp(self, to_outnode or from_outnode, to_outname or from_outname, - to_innode or from_innode, to_inname or from_inname) - - op = CompoundAsyncSceneOperation(self, (op1, op2)) - op.do(callback) - - def change_connection_by_id(self, con_id, *, - to_outnode_id: Optional[int] = None, to_outname: Optional[str] = None, - to_innode_id: Optional[int] = None, to_inname: Optional[str] = None, - callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): - con = self.get_node_connection(con_id) - if con is None: - return - cin = con.input() - cout = con.output() - - return self.change_connection(cout[0].get_id(), cout[1], cin[0].get_id(), cin[1], - to_outnode_id=to_outnode_id, to_outname=to_outname, - to_innode_id=to_innode_id, to_inname=to_inname, - callback=callback) - - def change_node_parameter(self, node_id: int, item: Parameter, value: Any = ..., expression=..., - *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): - """ - - :param node_id: - :param item: - :param value: ... means no change - :param expression: ... means no change - :param callback: optional callback to call on successful completion of async operation - :return: - """ - logger.debug(f'node:{node_id}, changing "{item.name()}" to {repr(value)}/({expression})') - node_sid = self._session_node_id_from_id(node_id) - op = ParameterChangeOp(self, self.get_node(node_id), item.name(), value, expression) - op.do(callback) - - def rename_node(self, node_id: int, new_name: str, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): - node = self.get_node(node_id) - if node is None: - logger.warning(f'cannot move node: node not found') - - op = RenameNodeOp(self, node, new_name) - op.do(callback) - - # - - def fetch_log_run_callback(self, invocation_id, callback: Callable[[InvocationLogData, Any], None], callback_data: Any = None): - """ - fetch log for given invocation and run callback - - callback is run only in case of success - """ - def _fetch_open_log_longop(longop: LongOperation): - longop.set_op_status(None, f"fetching log for {invocation_id}") - self.request_log(invocation_id, LongOperationData(longop)) - _, logss = yield # type: int, Dict[int, Dict[int, InvocationLogData]] - if len(logss) == 0: - logger.error(f'could not find logs for {invocation_id}') - return - elif len(logss) > 1: - logger.error(f'unexpected error! {invocation_id} returned multiple nodes: {list(logss.keys())}') - return - logs = list(logss.values())[0] # expect single entry in logs - if invocation_id not in logs: - logger.error(f'could not find logs for {invocation_id}') - return - log: InvocationLogData = logs[invocation_id] - - if callback: - callback(log, callback_data) - - self.add_long_operation(_fetch_open_log_longop) - - # undoes, also async - - def _undo_stack(self): + def undo_stack(self) -> UndoStack: return self.__undo_stack def undo(self, count=1) -> List[UndoableOperation]: @@ -572,647 +34,8 @@ def undo_stack_names(self) -> List[str]: return self.__undo_stack.operation_names() # - # scheduler update events - # - - @Slot(object) - def db_uid_update(self, new_db_uid: int): - if self.__db_uid is not None and self.__db_uid != new_db_uid: - logger.info('scheduler\'s database changed. resetting the view...') - self.save_node_layout() - self.clear() - self.__db_uid = None - self.__nodes_table_name = None - self.reset_undo_stack() - # this means we probably reconnected to another scheduler, so existing nodes need to be dropped - - if self.__db_uid is None: - self.__db_uid = new_db_uid - self.__nodes_table_name = f'nodes_{self.__db_uid}' - with sqlite3.connect(self.__db_path) as con: - con.executescript(sql_init_script_nodes.format(db_uid=self.__db_uid)) - self.reset_undo_stack() - - @timeit(0.05) - @Slot(object) - def graph_full_update(self, graph_data: NodeGraphStructureData): - if self.__db_uid != graph_data.db_uid: - logger.warning(f'received node graph update with a differend db uid. Maybe a ghost if scheduler has just switched db. Ignoring. expect: {self.__db_uid}, got: {graph_data.db_uid}') - return - - to_del = [] - existing_node_ids: Dict[int, Node] = {} - existing_conn_ids: Dict[int, NodeConnection] = {} - - for item in self.items(): - if isinstance(item, Node): - if item.get_id() not in graph_data.nodes or item.node_type() != graph_data.nodes[item.get_id()].type: - to_del.append(item) - continue - existing_node_ids[item.get_id()] = item - elif isinstance(item, NodeConnection): - if item.get_id() not in graph_data.connections: - to_del.append(item) - continue - existing_conn_ids[item.get_id()] = item - print('---') - print(existing_node_ids) - print('---') - - # delete things - for item in to_del: - self.removeItem(item) - - # removing items might cascade things, like removing node will remove connections to that node - # so now we need to recheck existing items validity - for existings in (existing_node_ids, existing_conn_ids): - for item_id, item in tuple(existings.items()): - if item.scene() != self: - del existings[item_id] - - # create new nodes, update node names (node parameters are NOT part of graph data) - nodes_to_layout = [] - for id, new_node_data in graph_data.nodes.items(): - if id in existing_node_ids: - existing_node_ids[id].set_name(new_node_data.name) - continue - new_node = self.__scene_item_factory.make_node(self, id, new_node_data.type, new_node_data.name or f'node #{id}') - try: - new_node.setPos(*self.node_position(id)) - except ValueError: - nodes_to_layout.append(new_node) - existing_node_ids[id] = new_node - self.addItem(new_node) - - # now check if there are task updates that we received before node updates - for task_id, node_id in self.__tasks_to_try_reparent_during_node_update.items(): - if node_id in existing_node_ids: - task = self.get_task(task_id) - if task is None: # may has already been deleted by another tasks update - continue - existing_node_ids[node_id].add_task(task) - else: - task = self.get_task(task_id) - logger.warning(f'could not find node_id {node_id} for an orphaned during update task {task_id} ({task})') - - # - # add connections - for id, new_conn_data in graph_data.connections.items(): - if id in existing_conn_ids: - # ensure connections - innode, inname = existing_conn_ids[id].input() - outnode, outname = existing_conn_ids[id].output() - if innode.get_id() != new_conn_data.in_id or inname != new_conn_data.in_name: - existing_conn_ids[id].set_input(existing_node_ids[new_conn_data.in_id], new_conn_data.in_name) - existing_conn_ids[id].update() - if outnode.get_id() != new_conn_data.out_id or outname != new_conn_data.out_name: - existing_conn_ids[id].set_output(existing_node_ids[new_conn_data.out_id], new_conn_data.out_name) - existing_conn_ids[id].update() - continue - new_conn = self.__scene_item_factory.make_node_connection( - self, - id, - existing_node_ids[new_conn_data.out_id], - existing_node_ids[new_conn_data.in_id], - new_conn_data.out_name, new_conn_data.in_name - ) - existing_conn_ids[id] = new_conn - self.addItem(new_conn) - - if nodes_to_layout: - self.layout_nodes(nodes_to_layout) - - print('+++') - print(self.items()) - print(self.nodes()) - - @timeit(0.05) - @Slot(object, bool) - def tasks_process_events(self, events: List[TaskEvent], first_time_getting_events: bool): - """ - - :param events: - :param first_time_getting_events: True if it's a first event batch since filter change - :return: - """ - for event in events: - logger.debug(f'event: {event.tiny_repr()}') - if event.database_uid != self.__db_uid: - logger.warning(f'received event with a differend db uid. Maybe a ghost if scheduler has just switched db. Ignoring. expect: {self.__db_uid}, got: {event.database_uid}') - continue - - if isinstance(event, TaskFullState): - self.tasks_full_update(event.task_data) - elif isinstance(event, TasksUpdated): - self.tasks_update(event.task_data) - elif isinstance(event, TasksChanged): - self.tasks_deltas_apply(event.task_deltas) #, animated=not first_time_getting_events) - elif isinstance(event, TasksRemoved): - existing_tasks = dict(self.tasks_dict()) - for task_id in event.task_ids: - if task_id in existing_tasks: - self.removeItem(existing_tasks[task_id]) - existing_tasks.pop(task_id) - - def tasks_deltas_apply(self, task_deltas: List[TaskDelta]): - for task_delta in task_deltas: - task_id = task_delta.id - task = self.get_task(task_id) - if task is None: # this would be unusual - logger.warning(f'cannot apply task delta: task {task_id} does not exist') - continue - if task_delta.node_id is not DataNotSet: - node = self.get_node(task_delta.node_id) - if node is None: - logger.warning('node not found during task delta processing, this will probably be fixed during next update') - self.__tasks_to_try_reparent_during_node_update[task_id] = task_delta.node_id - task.apply_task_delta(task_delta, self.get_node) - - @timeit(0.05) - @Slot(object) - def tasks_full_update(self, tasks_data: TaskBatchData): - if self.__db_uid != tasks_data.db_uid: - logger.warning(f'received node graph update with a differend db uid. Maybe a ghost if scheduler has just switched db. Ignoring. expect: {self.__db_uid}, got: {tasks_data.db_uid}') - return - - to_del = [] - to_del_tasks = {} - existing_task_ids: Dict[int, Task] = {} - - for item in self.tasks(): - if item.get_id() not in tasks_data.tasks: - to_del.append(item) - if item.node() is not None: - if not item.node() in to_del_tasks: - to_del_tasks[item.node()] = [] - to_del_tasks[item.node()].append(item) - continue - existing_task_ids[item.get_id()] = item - - for node, tasks in to_del_tasks.items(): - node.remove_tasks(tasks) - - for item in to_del: - self.removeItem(item) - - # we don't need that cuz scene already takes care of upkeeping task dict - # # removing items might cascade things, like removing node will remove connections to that node - # # so now we need to recheck existing items validity - # # though not consistent scene states should not come in uidata at all - # for item_id, item in tuple(existing_task_ids.items()): - # if item.scene() != self: - # del existing_task_ids[item_id] - - self.tasks_update(tasks_data) - - def tasks_update(self, tasks_data: TaskBatchData): - """ - unlike tasks_full_update - this ONLY applies updates, does not delete anything - - :param tasks_data: - :param existing_tasks: optional already computed dict of existing tasks. if none - it will be computed - :return: - """ - - existing_tasks = dict(self.tasks_dict()) - - for id, new_task_data in tasks_data.tasks.items(): - if id not in existing_tasks: - new_task = self.__scene_item_factory.make_task(self, new_task_data) - existing_tasks[id] = new_task - if new_task_data.split_origin_task_id is not None and new_task_data.split_origin_task_id in existing_tasks: # TODO: bug: this and below will only work if parent/original tasks were created during previous updates - origin_task = existing_tasks[new_task_data.split_origin_task_id] - new_task.setPos(origin_task.scenePos()) - elif new_task_data.parent_id is not None and new_task_data.parent_id in existing_tasks: - origin_task = existing_tasks[new_task_data.parent_id] - new_task.setPos(origin_task.scenePos()) - self.addItem(new_task) - task = existing_tasks[id] - existing_node = self.get_node(new_task_data.node_id) - if existing_node: - existing_node.add_task(task) - else: - self.__tasks_to_try_reparent_during_node_update[id] = new_task_data.node_id - task.set_task_data(new_task_data) - - # @timeit(0.05) - # @Slot(object) - # def full_update(self, uidata: UiData): - # raise DeprecationWarning('no use') - # # logger.debug('full_update') - # - # if self.__db_uid is not None and self.__db_uid != uidata.db_uid: - # logger.info('scheduler\'s database changed. resetting the view...') - # self.save_node_layout() - # self.clear() - # self.__db_uid = None - # self.__nodes_table_name = None - # # this means we probably reconnected to another scheduler, so existing nodes need to be dropped - # - # if self.__db_uid is None: - # self.__db_uid = uidata.db_uid - # self.__nodes_table_name = f'nodes_{self.__db_uid}' - # with sqlite3.connect(self.__db_path) as con: - # con.executescript(sql_init_script_nodes.format(db_uid=self.__db_uid)) - # - # to_del = [] - # to_del_tasks = {} - # existing_node_ids: Dict[int, Node] = {} - # existing_conn_ids: Dict[int, NodeConnection] = {} - # existing_task_ids: Dict[int, Task] = {} - # _perf_total = 0.0 - # graph_data = uidata.graph_data - # with performance_measurer() as pm: - # for item in self.items(): - # if isinstance(item, Node): # TODO: unify this repeating code and move the setting attribs to after all elements are created - # if item.get_id() not in graph_data.nodes or item.node_type() != graph_data.nodes[item.get_id()].type: - # to_del.append(item) - # continue - # existing_node_ids[item.get_id()] = item - # # TODO: update all kind of attribs here, for now we just don't have any - # elif isinstance(item, NodeConnection): - # if item.get_id() not in graph_data.connections: - # to_del.append(item) - # continue - # existing_conn_ids[item.get_id()] = item - # # TODO: update all kind of attribs here, for now we just don't have any - # elif isinstance(item, Task): - # if item.get_id() not in uidata.tasks.tasks: - # to_del.append(item) - # if item.node() is not None: - # if not item.node() in to_del_tasks: - # to_del_tasks[item.node()] = [] - # to_del_tasks[item.node()].append(item) - # continue - # existing_task_ids[item.get_id()] = item - # _perf_item_classify = pm.elapsed() - # _perf_total += pm.elapsed() - # - # # before we delete everything - we'll remove tasks from nodes to avoid deleting tasks one by one triggering tonns of animation - # with performance_measurer() as pm: - # for node, tasks in to_del_tasks.items(): - # node.remove_tasks(tasks) - # _perf_remove_tasks = pm.elapsed() - # _perf_total += pm.elapsed() - # with performance_measurer() as pm: - # for item in to_del: - # self.removeItem(item) - # _perf_remove_items = pm.elapsed() - # _perf_total += pm.elapsed() - # # removing items might cascade things, like removing node will remove connections to that node - # # so now we need to recheck existing items validity - # # though not consistent scene states should not come in uidata at all - # with performance_measurer() as pm: - # for existings in (existing_node_ids, existing_task_ids, existing_conn_ids): - # for item_id, item in tuple(existings.items()): - # if item.scene() != self: - # del existings[item_id] - # _perf_revalidate = pm.elapsed() - # _perf_total += pm.elapsed() - # - # nodes_to_layout = [] - # with performance_measurer() as pm: - # for id, new_node_data in graph_data.nodes.items(): - # if id in existing_node_ids: - # existing_node_ids[id].set_name(new_node_data.name) - # continue - # new_node = self.__scene_item_factory.make_node(self, id, new_node_data.type, new_node_data.name or f'node #{id}') - # try: - # new_node.setPos(*self.node_position(id)) - # except ValueError: - # nodes_to_layout.append(new_node) - # existing_node_ids[id] = new_node - # self.addItem(new_node) - # _perf_create_nodes = pm.elapsed() - # _perf_total += pm.elapsed() - # - # with performance_measurer() as pm: - # for id, new_conn_data in graph_data.connections.items(): - # if id in existing_conn_ids: - # # ensure connections - # innode, inname = existing_conn_ids[id].input() - # outnode, outname = existing_conn_ids[id].output() - # if innode.get_id() != new_conn_data.in_id or inname != new_conn_data.in_name: - # existing_conn_ids[id].set_input(existing_node_ids[new_conn_data.in_id], new_conn_data.in_name) - # existing_conn_ids[id].update() - # if outnode.get_id() != new_conn_data.out_id or outname != new_conn_data.out_name: - # existing_conn_ids[id].set_output(existing_node_ids[new_conn_data.out_id], new_conn_data.out_name) - # existing_conn_ids[id].update() - # continue - # new_conn = self.__scene_item_factory.make_node_connection( - # self, - # id, - # existing_node_ids[new_conn_data.out_id], - # existing_node_ids[new_conn_data.in_id], - # new_conn_data.out_name, new_conn_data.in_name - # ) - # existing_conn_ids[id] = new_conn - # self.addItem(new_conn) - # _perf_create_connections = pm.elapsed() - # _perf_total += pm.elapsed() + # long operations # - # with performance_measurer() as pm: - # for id, new_task_data in uidata.tasks.tasks.items(): - # if id not in existing_task_ids: - # new_task = self.__scene_item_factory.make_task(self, new_task_data) - # existing_task_ids[id] = new_task - # if new_task_data.split_origin_task_id is not None and new_task_data.split_origin_task_id in existing_task_ids: # TODO: bug: this and below will only work if parent/original tasks were created during previous updates - # origin_task = existing_task_ids[new_task_data.split_origin_task_id] - # new_task.setPos(origin_task.scenePos()) - # elif new_task_data.parent_id is not None and new_task_data.parent_id in existing_task_ids: - # origin_task = existing_task_ids[new_task_data.parent_id] - # new_task.setPos(origin_task.scenePos()) - # self.addItem(new_task) - # task = existing_task_ids[id] - # existing_node_ids[new_task_data.node_id].add_task(task) - # task.set_task_data(new_task_data) - # _perf_create_tasks = pm.elapsed() - # _perf_total += pm.elapsed() - # - # # now layout nodes that need it - # with performance_measurer() as pm: - # if nodes_to_layout: - # self.layout_nodes(nodes_to_layout) - # _perf_layout = pm.elapsed() - # _perf_total += pm.elapsed() - # - # with performance_measurer() as pm: - # if self.__all_task_groups != uidata.task_groups: - # self.__all_task_groups = uidata.task_groups - # self.task_groups_updated.emit(uidata.task_groups) - # _perf_task_groups_update = pm.elapsed() - # _perf_total += pm.elapsed() - # - # if _perf_total > 0.04: # arbitrary threshold ~ 1/25 of a sec - # logger.debug(f'update performed:\n' - # f'{_perf_item_classify:.04f}:\tclassify\n' - # f'{_perf_remove_tasks:.04f}:\tremove tasks\n' - # f'{_perf_remove_items:.04f}:\tremove items\n' - # f'{_perf_revalidate:.04f}:\trevalidate\n' - # f'{_perf_create_nodes:.04f}:\tcreate nodes\n' - # f'{_perf_create_connections:.04f}:\tcreate connections\n' - # f'{_perf_create_tasks:.04f}:\tcreate tasks\n' - # f'{_perf_layout:.04f}:\tlayout\n' - # f'{_perf_task_groups_update:.04f}:\ttask group update') - - @Slot(object, object, bool, object) - def log_fetched(self, task_id: int, log: Dict[int, Dict[int, Union[IncompleteInvocationLogData, InvocationLogData]]], full_update, data: Optional["LongOperationData"] = None): - """ - this slot to be connected to data provider, whenever log is fetched - this method should be called - - full_update is true, if log dict covers all invocations. - otherwise update is considered partial, so only updated information counts, no removes are to be done - """ - if task_id >= 0: # otherwise it means empty event - task = self.get_task(task_id) - if task is None: - logger.warning(f'log fetched, but task not found! {task_id}') - else: - task.update_log(log, full_update) - - if data is not None: - data.data = (task_id, log) - self.process_operation(data) - - @Slot(object, object) - def nodeui_fetched(self, node_id: int, nodeui: NodeUi): - node = self.get_node(node_id) - if node is None: - logger.warning('node ui fetched for non existant node') - return - node.update_nodeui(nodeui) - - @Slot(object, object, object) - def _task_attribs_fetched(self, task_id: int, all_attribs: Tuple[dict, Optional[EnvironmentResolverArguments]], data: Optional["LongOperationData"] = None): - task = self.get_task(task_id) - attribs, env_attribs = all_attribs - if task is None: - logger.warning('attribs fetched, but task not found!') - else: - task.update_attributes(attribs) - task.set_environment_attributes(env_attribs) - if data is not None: - data.data = attribs - self.process_operation(data) - - @Slot(object, object) - def _task_invocation_job_fetched(self, task_id: int, invjob: InvocationJob): - self.task_invocation_job_fetched.emit(task_id, invjob) - - @Slot(int, str, bool, object) - def _node_has_parameter(self, node_id, param_name, exists, data: Optional["LongOperationData"] = None): - if data is not None: - data.data = (node_id, param_name, exists) - self.process_operation(data) - - @Slot(int, object, object, object) - def _node_parameter_changed(self, node_id, param, newval, data: Optional["LongOperationData"] = None): - if data is not None: - data.data = (node_id, param.name(), newval) - self.process_operation(data) - - @Slot(int, object, object, object) - def _node_parameters_changed(self, node_id, params, newvals, data: Optional["LongOperationData"] = None): - if data is not None: - data.data = (node_id, tuple(param.name() for param in params), newvals) - self.process_operation(data) - - @Slot(int, object, object) - def _node_parameter_expression_changed(self, node_id, param, data: Optional["LongOperationData"] = None): - if data is not None: - data.data = (node_id, param.name()) - self.process_operation(data) - - @Slot(int, object, object) - def _node_settings_applied(self, node_id, settings_name, data: Optional["LongOperationData"] = None): - node = self.get_node(node_id) - if node is not None: - self.request_node_ui(node_id) - if data is not None: - data.data = (node_id, settings_name) # TODO: add return status here? - self.process_operation(data) - - @Slot(str, str, object) - def _node_custom_settings_saved(self, type_name: str, settings_name: str, data: Optional["LongOperationData"] = None): - if data is not None: - data.data = (type_name, settings_name) # TODO: add return status here? - self.process_operation(data) - - @Slot(str, str, object) - def _node_default_settings_set(self, type_name: str, settings_name: Optional[str], data: Optional["LongOperationData"] = None): - if data is not None: - data.data = (type_name, settings_name) # TODO: add return status here? - self.process_operation(data) - - @Slot(int, str, str, object, object) - def _node_created(self, node_id, node_type, node_name, pos, data: Optional["LongOperationData"] = None): - node = self.__scene_item_factory.make_node(self, node_id, node_type, node_name) - node.setPos(pos) - self.addItem(node) - if data is not None: - data.data = (node_id, node_type, node_name) - self.process_operation(data) - - def _nodes_removed(self, node_ids: List[int], failed_node_ids: List[Tuple[int, str]], data: Optional["LongOperationData"] = None): - for node_id in node_ids: - node = self.get_node(node_id) - if node is not None: - self.removeItem(node) - if data is not None: - data.data = (node_ids, failed_node_ids) - self.process_operation(data) - - def _node_renamed(self, node_id: int, new_name: str, data: Optional["LongOperationData"] = None): - node = self.get_node(node_id) - if node is not None: - old_name = node.node_name() - node.set_name(new_name) - if data is not None: - data.data = (node_id, new_name) - self.process_operation(data) - - @Slot(object, object) - def _nodes_duplicated(self, old_to_new: Dict[int, int], shift: QPointF): - for old_id, new_id in old_to_new.items(): - old_pos = QPointF() - old_node = self.get_node(old_id) - if old_node is not None: - old_pos = old_node.pos() - self.set_node_position(new_id, old_pos + shift) - - @Slot(list, object) - def _node_connections_removed(self, con_ids: List[int], failed_con_ids: List[Tuple[int, str]], data: Optional["LongOperationData"] = None): - for con_id in con_ids: - con = self.get_node_connection(con_id) - if con is not None: - self.removeItem(con) - if data is not None: - data.data = (con_ids, failed_con_ids) - self.process_operation(data) - - @Slot(list, object) - def _node_connections_added(self, cons: List[Tuple[int, int, str, int, str]], data: Optional["LongOperationData"] = None): - for new_id, outnode_id, outname, innode_id, inname in cons: - outnode = self.get_node(outnode_id) - innode = self.get_node(innode_id) - if outnode is None or innode is None: - return - new_conn = self.__scene_item_factory.make_node_connection(self, new_id, outnode, innode, outname, inname) - self.addItem(new_conn) - if data is not None: - data.data = ([x[0] for x in cons],) - self.process_operation(data) - - @Slot(int, int, str) - def _node_task_set(self, task_id: int, node_id: int, error: Optional[str]): - # no need to do anything - task update will be polled by connection worker - if error is not None: - self.unhandled_error_happened.emit(error) - - @Slot(object) - def _nodetypes_fetched(self, nodetypes): - self.__cached_nodetypes = nodetypes - self.nodetypes_updated.emit(nodetypes) - - @Slot(object) - def _nodepresets_fetched(self, nodepresets: Dict[str, Dict[str, Union[NodeSnippetData, NodeSnippetDataPlaceholder]]]): - # here we receive just the list of names, no contents, so we dont just update dicts - for package, presets in nodepresets.items(): - self.__cached_nodepresets.setdefault(package, {}) - for preset_name, preset_meta in presets.items(): - if preset_name not in self.__cached_nodepresets[package]: - self.__cached_nodepresets[package][preset_name] = preset_meta - presets_set = set(presets) - keys_to_del = [] - for key in self.__cached_nodepresets[package]: - if key not in presets_set: - keys_to_del.append(key) - for key in keys_to_del: - del self.__cached_nodepresets[package][key] - - self.nodepresets_updated.emit(self.__cached_nodepresets) - # {(pack, label): snippet for pack, packpres in self.__cached_nodepresets.items() for label, snippet in packpres.items()} - - @Slot(object, object) - def _nodepreset_fetched(self, package: str, preset: str, snippet: NodeSnippetData, data: Optional["LongOperationData"] = None): - self.nodepreset_received.emit(package, preset, snippet) - if data is not None: - data.data = (package, preset, snippet) - self.process_operation(data) - - @Slot(NodeSnippetData, QPointF) - def nodes_from_snippet(self, snippet: NodeSnippetData, pos: QPointF, containing_long_op: Optional[LongOperation] = None): - op = CreateNodesOp(self, snippet, pos) - op.do() - - def _request_create_nodes_from_snippet(self, snippet: NodeSnippetData, pos: QPointF, containing_long_op: Optional[LongOperation] = None): - def pasteop(longop): - - tmp_to_new: Dict[int, int] = {} - created_nodes = [] # select delayed to ensure it happens after all changes to parameters - - # for ui progress - total_elements = len(snippet.nodes_data) + len(snippet.connections_data) - current_element = 0 - opname = 'pasting nodes' - - for nodedata in snippet.nodes_data: - current_element += 1 - if total_elements > 1: - longop.set_op_status(current_element / (total_elements - 1), opname) - self.request_create_node(nodedata.type, nodedata.name, QPointF(*nodedata.pos) + pos - QPointF(*snippet.pos), LongOperationData(longop, None)) - # NOTE: there is currently no mechanism to ensure order of results when more than one things are requested - # from the same operation. So we request and wait things one by one - node_id, _, _ = yield - tmp_to_new[nodedata.tmpid] = node_id - created_nodes.append(node_id) - - # assign session ids to new nodes, prefer tmp ids from the snippet - if self._session_node_id_to_id(nodedata.tmpid) is None: # session id is free - self._session_node_update_session_id(nodedata.tmpid, node_id) - - proxy_params = [] - for param_name, param_data in nodedata.parameters.items(): - proxy_param = Parameter(param_name, None, param_data.type, param_data.uvalue) - if param_data.expr is not None: - proxy_param.set_expression(param_data.expr) - proxy_params.append(proxy_param) - self.request_node_parameters_change(node_id, proxy_params, LongOperationData(longop, None)) - yield - - for node_id in created_nodes: # selecting - self.get_node(node_id).setSelected(True) - - for conndata in snippet.connections_data: - current_element += 1 - if total_elements > 1: - longop.set_op_status(current_element / (total_elements - 1), opname) - - con_out = tmp_to_new.get(conndata.tmpout, self._session_node_id_to_id(conndata.tmpout)) - con_in = tmp_to_new.get(conndata.tmpin, self._session_node_id_to_id(conndata.tmpin)) - if con_out is None or con_in is None: - logger.warning('failed to create connection during snippet creation!') - continue - self.request_node_connection_add(con_out, conndata.out_name, - con_in, conndata.in_name, LongOperationData(longop)) - yield - - if total_elements > 1: - longop.set_op_status(1.0, opname) - if containing_long_op is not None: - self.process_operation(LongOperationData(containing_long_op, tuple(created_nodes))) - - self.clearSelection() - self.add_long_operation(pasteop) - - @Slot(str, str, dict) - def save_nodetype_settings(self, node_type_name: str, settings_name: str, settings: Dict[str, Any]): - def savesettingsop(longop): - self.request_save_custom_settings(node_type_name, settings_name, settings, longop.new_op_data()) - yield # wait for operation to complete - self.request_node_types_update() - - self.add_long_operation(savesettingsop) @Slot(LongOperationData) def process_operation(self, op: LongOperationData): @@ -1266,236 +89,3 @@ def _op_status_list(ops) -> Tuple[Tuple[int, Tuple[Optional[float], str]], ...]: return _op_status_list(x[0] for x in self.__long_operations.values()), \ {qname: len(qval) for qname, qval in self.__long_op_queues.items()} - - # - # query - # - - def get_node_by_session_id(self, node_session_id) -> Optional[Node]: - node_id = self._session_node_id_to_id(node_session_id) - if node_id is None: - return None - return self.get_node(node_id, None) - - def find_nodes_by_name(self, name: str, match_partly=False) -> Set[Node]: - if match_partly: - match_fn = lambda x, y: x in y - else: - match_fn = lambda x, y: x == y - matched = set() - for node in self.nodes(): - if match_fn(name, node.node_name()): - matched.add(node) - - return matched - - # - # - # - - def start(self): - if self.__ui_connection_thread is None: - return - self.__ui_connection_thread.start() - - def stop(self): - if self.__ui_connection_thread is None: - for meth in dir(self): # disconnect all signals from worker slots - if not meth.startswith('_signal_'): - continue - try: - getattr(self, meth).disconnect() - except RuntimeError as e: - logger.warning(f'error disconnecting signal {meth}: {e}') - - # disconnect from worker's signals too - self.__ui_connection_worker.disconnect(self) - return - # if thread is not none - means we created thread AND worker, so we manage them both - self.__ui_connection_worker.request_interruption() - self.__ui_connection_thread.exit() - self.__ui_connection_thread.wait() - - def save_node_layout(self): - if self.__db_path is None: - return - - nodes_to_save = [item for item in self.items() if isinstance(item, Node)] - if len(nodes_to_save) == 0: - return - if self.__db_uid is None: - logger.warning('db uid is not set while saving nodes') - - if self.__nodes_table_name is None: - raise RuntimeError('node positions requested before db uid set') - - with sqlite3.connect(self.__db_path) as con: - con.row_factory = sqlite3.Row - for item in nodes_to_save: - con.execute(f'INSERT OR REPLACE INTO "{self.__nodes_table_name}" ("id", "posx", "posy") ' - f'VALUES (?, ?, ?)', (item.get_id(), *item.pos().toTuple())) - con.commit() - - def keyPressEvent(self, event: QKeyEvent) -> None: - for item in self.selectedItems(): - item.keyPressEvent(event) - event.accept() - # return super(QGraphicsImguiScene, self).keyPressEvent(event) - - def keyReleaseEvent(self, event: QKeyEvent) -> None: - for item in self.selectedItems(): - item.keyReleaseEvent(event) - event.accept() - # return super(QGraphicsImguiScene, self).keyReleaseEvent(event) - - # this will also catch accumulated events that wires ignore to determine the closest wire - def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: - item_event_candidates: List[Tuple[float, QGraphicsItemExtended]] = [] - event.item_event_candidates = item_event_candidates - super(QGraphicsImguiScene, self).mousePressEvent(event) - logger.debug(f'press mouse grabber={self.mouseGrabberItem()}') - if not event.isAccepted() and len(event.item_event_candidates) > 0: - logger.debug('closest candidates: %s', ', '.join([str(x[0]) for x in event.item_event_candidates])) - closest = min(event.item_event_candidates, key=lambda x: x[0]) - closest[1].post_mousePressEvent(event) - elif not event.isAccepted() and self.mouseGrabberItem() is None: - logger.debug('probably started selecting') - self.__selection_happening = True - - def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: - super(QGraphicsImguiScene, self).mouseReleaseEvent(event) - logger.debug(f'release mouse grabber={self.mouseGrabberItem()}') - if not event.isAccepted() and self.mouseGrabberItem() is None and self.__selection_happening: - logger.debug('probably ended selecting') - self.__selection_happening = False - - def setSelectionArea(self, *args, **kwargs): - pass - - # - # layout - def layout_nodes(self, nodes: Optional[Iterable[Node]] = None, center: Optional[QPointF] = None): - if center is None: - center = QPointF(0, 0) - if nodes is None: - nodes = self.nodes() - - if not nodes: - return - - nodes_to_vertices = {x: grandalf.graphs.Vertex(x) for x in nodes} - graph = grandalf.graphs.Graph(nodes_to_vertices.values()) - lower_fixed = [] - upper_fixed = [] - - for node in nodes: - for output_name in node.output_names(): - for conn in node.output_connections(output_name): - nextnode, _ = conn.input() - if nextnode not in nodes_to_vertices and nextnode not in lower_fixed: - lower_fixed.append(nextnode) - if nextnode not in lower_fixed and nextnode not in upper_fixed: - graph.add_edge(grandalf.graphs.Edge(nodes_to_vertices[node], nodes_to_vertices[nextnode])) - for input_name in node.input_names(): - for conn in node.input_connections(input_name): - prevnode, _ = conn.output() - if prevnode not in nodes_to_vertices and prevnode not in upper_fixed: - upper_fixed.append(prevnode) - if prevnode not in lower_fixed and prevnode not in upper_fixed: - # double edges will be filtered by networkx, and we wont miss any connection to external nodes this way - graph.add_edge(grandalf.graphs.Edge(nodes_to_vertices[prevnode], nodes_to_vertices[node])) - - upper_middle_point = QPointF(0, float('inf')) - lower_middle_point = None - if len(lower_fixed) > 0: - for lower in lower_fixed: - upper_middle_point.setX(upper_middle_point.x() + lower.pos().x()) - upper_middle_point.setY(min(upper_middle_point.y(), lower.pos().y())) - upper_middle_point.setX(upper_middle_point.x() / len(lower_fixed)) - else: - upper_middle_point = center - - if len(upper_fixed) > 0: - lower_middle_point = QPointF(0, -float('inf')) - for upper in upper_fixed: - lower_middle_point.setX(lower_middle_point.x() + upper.pos().x()) - lower_middle_point.setY(max(lower_middle_point.y(), upper.pos().y())) - lower_middle_point.setX(lower_middle_point.x() / len(upper_fixed)) - - class _viewhelper: - def __init__(self, w, h): - self.w = w - self.h = h - - for node, vert in nodes_to_vertices.items(): - bounds = node.boundingRect() # type: QRectF - vert.view = _viewhelper(*bounds.size().toTuple()) - vert.view.h *= 1.5 - - vertices_to_nodes = {v: k for k, v in nodes_to_vertices.items()} - - xshift = 0 - nodewidgh = next(graph.V()).view.w # just take first for now - nodeheight = nodewidgh - upper_middle_point -= QPointF(0, 1.5 * nodeheight) - if lower_middle_point is not None: - lower_middle_point += QPointF(0, 1.5 * nodeheight) - # graph.C[0].layers[0].sV[0] - for component in graph.C: - layout = grandalf.layouts.SugiyamaLayout(component) - layout.init_all() - layout.draw() - - xmax = -float('inf') - ymax = -float('inf') - xmin = float('inf') - ymin = float('inf') - xshiftpoint = QPointF(xshift, 0) - for vertex in component.sV: - xmax = max(xmax, vertex.view.xy[0]) - ymax = max(ymax, vertex.view.xy[1]) - xmin = min(xmin, vertex.view.xy[0]) - ymin = min(ymin, vertex.view.xy[1]) - if len(lower_fixed) > 0 or lower_middle_point is None: - for vertex in component.sV: - vertices_to_nodes[vertex].setPos(QPointF(*vertex.view.xy) + xshiftpoint - QPointF((xmax + xmin) / 2, 0) + (upper_middle_point - QPointF(0, ymax))) - else: - for vertex in component.sV: - vertices_to_nodes[vertex].setPos(QPointF(*vertex.view.xy) + xshiftpoint - QPointF((xmax + xmin) / 2, 0) + (lower_middle_point - QPointF(0, ymin))) - xshift += (xmax - xmin) + 2 * nodewidgh - - # def layout_nodes(self, nodes: Optional[Iterable[Node]] = None): - # if nodes is None: - # nodes = self.nodes() - # - # nodes_set = set(nodes) - # graph = networkx.Graph() # wierdly digraph here works way worse for layout - # graph.add_nodes_from(nodes) - # fixed = [] - # for node in nodes: - # for output_name in node.output_names(): - # for conn in node.output_connections(output_name): - # nextnode, _ = conn.input() - # if nextnode not in nodes_set: - # nodes_set.add(nextnode) - # fixed.append(nextnode) - # graph.add_edge(node, nextnode) - # for input_name in node.input_names(): - # for conn in node.input_connections(input_name): - # prevnode, _ = conn.output() - # if prevnode not in nodes_set: - # nodes_set.add(prevnode) - # fixed.append(prevnode) - # # double edges will be filtered by networkx, and we wont miss any connection to external nodes this way - # graph.add_edge(prevnode, node) - # print(len(nodes_set), len(graph), len(fixed)) - # init_pos = {node: (node.pos()).toTuple() for node in nodes_set} - # print(graph) - # print(graph.edges) - # if not fixed: - # fixed.append(next(iter(nodes_set))) - # final_pos = networkx.drawing.layout.spring_layout(graph, 150, pos=init_pos, fixed=fixed or None, iterations=5) - # from pprint import pprint - # pprint(final_pos) - # for node, pos in final_pos.items(): - # node.setPos(QPointF(*pos)) diff --git a/src/lifeblood_viewer/graphics_scene_with_data_controller.py b/src/lifeblood_viewer/graphics_scene_with_data_controller.py new file mode 100644 index 00000000..031116c9 --- /dev/null +++ b/src/lifeblood_viewer/graphics_scene_with_data_controller.py @@ -0,0 +1,1419 @@ +import sqlite3 + +import grandalf.graphs +import grandalf.layouts + +from types import MappingProxyType +from .graphics_items import Task, Node, NodeConnection, GraphicsSceneWithNodesAndTasks +from .graphics_items.qextended_graphics_item import QGraphicsItemExtended +from .db_misc import sql_init_script_nodes +from .long_op import LongOperation, LongOperationData +from .connection_worker import SchedulerConnectionWorker +from .undo_stack import UndoableOperation, OperationCompletionDetails +from .ui_snippets import UiNodeSnippetData +from .scene_item_factory_base import SceneItemFactoryBase +from .scene_data_controller import SceneDataController +from .graphics_scene import GraphicsScene +from .scene_ops import ( + CompoundAsyncSceneOperation, + CreateNodeOp, CreateNodesOp, + RemoveNodesOp, RenameNodeOp, + MoveNodesOp, + AddConnectionOp, RemoveConnectionOp, + ParameterChangeOp) + +from lifeblood.misc import timeit, performance_measurer +from lifeblood.uidata import NodeUi, Parameter +from lifeblood.ui_protocol_data import UiData, TaskBatchData, NodeGraphStructureData, TaskDelta, DataNotSet, IncompleteInvocationLogData, InvocationLogData +from lifeblood.enums import TaskState, TaskGroupArchivedState +from lifeblood import logging +from lifeblood.node_type_metadata import NodeTypeMetadata +from lifeblood.taskspawn import NewTask +from lifeblood.invocationjob import InvocationJob +from lifeblood.snippets import NodeSnippetData, NodeSnippetDataPlaceholder +from lifeblood.environment_resolver import EnvironmentResolverArguments +from lifeblood.ui_events import TaskEvent, TasksRemoved, TasksUpdated, TasksChanged, TaskFullState +from lifeblood.config import get_config + +from PySide2.QtWidgets import * +from PySide2.QtCore import Slot, Signal, QThread, QRectF, QPointF +from PySide2.QtGui import QKeyEvent + +from typing import Callable, Generator, Optional, List, Mapping, Tuple, Dict, Set, Iterable, Union, Any, Sequence + +logger = logging.get_logger('viewer') + + +class QGraphicsImguiSceneWithDataController(GraphicsScene, SceneDataController): + # these are private signals to invoke shit on worker in another thread. QMetaObject's invokemethod is broken in pyside2 + _signal_log_has_been_requested = Signal(int, object) + _signal_log_meta_has_been_requested = Signal(int, object) + _signal_node_ui_has_been_requested = Signal(int) + _signal_task_ui_attributes_has_been_requested = Signal(int, object) + _signal_task_invocation_job_requested = Signal(int) + _signal_node_has_parameter_requested = Signal(int, str, object) + _signal_node_parameter_change_requested = Signal(int, object, object) + _signal_node_parameter_expression_change_requested = Signal(int, object, object) + _signal_node_parameters_change_requested = Signal(int, object, object) + _signal_node_apply_settings_requested = Signal(int, str, object) + _signal_node_save_custom_settings_requested = Signal(str, str, object, object) + _signal_node_set_settings_default_requested = Signal(str, object, object) + _signal_nodetypes_update_requested = Signal() + _signal_nodepresets_update_requested = Signal() + _signal_set_node_name_requested = Signal(int, str, object) + _signal_nodepreset_requested = Signal(str, str, object) + _signal_create_node_requested = Signal(str, str, QPointF, object) + _signal_remove_nodes_requested = Signal(list, object) + _signal_wipe_node_requested = Signal(int) + _signal_change_node_connection_requested = Signal(int, object, object, object, object) + _signal_remove_node_connections_requested = Signal(list, object) + _signal_add_node_connection_requested = Signal(int, str, int, str, object) + _signal_set_task_group_filter = Signal(set) + _signal_set_task_state = Signal(list, TaskState) + _signal_set_tasks_paused = Signal(object, bool) # object is Union[List[int], int, str] + _signal_set_task_group_state_requested = Signal(str, TaskGroupArchivedState) + _signal_set_task_node_requested = Signal(int, int) + _signal_set_task_name_requested = Signal(int, str) + _signal_set_task_groups_requested = Signal(int, set) + _signal_update_task_attributes_requested = Signal(int, dict, set) + _signal_cancel_task_requested = Signal(int) + _signal_add_task_requested = Signal(NewTask) + _signal_duplicate_nodes_requested = Signal(dict, QPointF) + _signal_set_skip_dead = Signal(bool) + _signal_set_skip_archived_groups = Signal(bool) + _signal_set_environment_resolver_arguments = Signal(int, EnvironmentResolverArguments) + _signal_unset_environment_resolver_arguments = Signal(int) + # + _signal_poke_graph_and_tasks_update = Signal() + _signal_poke_task_groups_update = Signal() + _signal_poke_workers_update = Signal() + # + # + nodetypes_updated = Signal(dict) # TODO: separate worker-oriented "private" signals for readability + nodepresets_updated = Signal(dict) + nodepreset_received = Signal(str, str, NodeSnippetData) + task_invocation_job_fetched = Signal(int, InvocationJob) + unhandled_error_happened = Signal(str) + + # + operation_started = Signal(int) # operation id + operation_progress_updated = Signal(int, str, float) # operation id, name, progress 0.0 - 1.0 + operation_finished = Signal(int) # operation id + + def __init__(self, scene_item_factory: SceneItemFactoryBase, db_path: str = None, worker: Optional["SchedulerConnectionWorker"] = None, parent=None): + super(QGraphicsImguiSceneWithDataController, self).__init__(parent=parent) + # to debug fuching bsp # self.setItemIndexMethod(QGraphicsScene.NoIndex) + self.__scene_item_factory: SceneItemFactoryBase = scene_item_factory + self.__db_path = db_path + self.__nodes_table_name = None + self.__cached_nodetypes: Dict[str, NodeTypeMetadata] = {} + self.__cached_nodepresets: Dict[str, Dict[str, Union[NodeSnippetData, NodeSnippetDataPlaceholder]]] = {} + self.__task_group_filter = None + self.__db_uid: Optional[int] = None # this is unique id of the scheduler's db. we use this to determine where to save node positions locally, not to mix and collide + + self.__tasks_to_try_reparent_during_node_update = {} + + self.__node_snapshots = {} # for undo/redo + self.__selection_happening = False + + if worker is None: + self.__ui_connection_thread = QThread(self) # SchedulerConnectionThread(self) + self.__ui_connection_worker = SchedulerConnectionWorker() + self.__ui_connection_worker.moveToThread(self.__ui_connection_thread) + + self.__ui_connection_thread.started.connect(self.__ui_connection_worker.start) + self.__ui_connection_thread.finished.connect(self.__ui_connection_worker.finish) + else: + self.__ui_connection_thread = None + self.__ui_connection_worker = worker + + self.__ui_connection_worker.graph_full_update.connect(self.graph_full_update) + self.__ui_connection_worker.tasks_full_update.connect(self.tasks_full_update) + self.__ui_connection_worker.tasks_events_arrived.connect(self.tasks_process_events) + self.__ui_connection_worker.db_uid_update.connect(self.db_uid_update) + self.__ui_connection_worker.log_fetched.connect(self.log_fetched) + self.__ui_connection_worker.nodeui_fetched.connect(self.nodeui_fetched) + self.__ui_connection_worker.task_attribs_fetched.connect(self._task_attribs_fetched) + self.__ui_connection_worker.task_invocation_job_fetched.connect(self._task_invocation_job_fetched) + self.__ui_connection_worker.nodetypes_fetched.connect(self._nodetypes_fetched) + self.__ui_connection_worker.nodepresets_fetched.connect(self._nodepresets_fetched) + self.__ui_connection_worker.nodepreset_fetched.connect(self._nodepreset_fetched) + self.__ui_connection_worker.node_has_parameter.connect(self._node_has_parameter) + self.__ui_connection_worker.node_parameter_changed.connect(self._node_parameter_changed) + self.__ui_connection_worker.node_parameters_changed.connect(self._node_parameters_changed) + self.__ui_connection_worker.node_parameter_expression_changed.connect(self._node_parameter_expression_changed) + self.__ui_connection_worker.node_settings_applied.connect(self._node_settings_applied) + self.__ui_connection_worker.node_custom_settings_saved.connect(self._node_custom_settings_saved) + self.__ui_connection_worker.node_default_settings_set.connect(self._node_default_settings_set) + self.__ui_connection_worker.node_created.connect(self._node_created) + self.__ui_connection_worker.nodes_removed.connect(self._nodes_removed) + self.__ui_connection_worker.node_renamed.connect(self._node_renamed) + self.__ui_connection_worker.nodes_copied.connect(self._nodes_duplicated) + self.__ui_connection_worker.node_connections_removed.connect(self._node_connections_removed) + self.__ui_connection_worker.node_connections_added.connect(self._node_connections_added) + self.__ui_connection_worker.node_task_set.connect(self._node_task_set) + + self._signal_log_has_been_requested.connect(self.__ui_connection_worker.get_log) + self._signal_log_meta_has_been_requested.connect(self.__ui_connection_worker.get_invocation_metadata) + self._signal_node_ui_has_been_requested.connect(self.__ui_connection_worker.get_nodeui) + self._signal_task_ui_attributes_has_been_requested.connect(self.__ui_connection_worker.get_task_attribs) + self._signal_node_has_parameter_requested.connect(self.__ui_connection_worker.send_node_has_parameter) + self._signal_node_parameter_change_requested.connect(self.__ui_connection_worker.send_node_parameter_change) + self._signal_node_parameter_expression_change_requested.connect(self.__ui_connection_worker.send_node_parameters_change) + self._signal_node_parameters_change_requested.connect(self.__ui_connection_worker.send_node_parameters_change) + self._signal_node_apply_settings_requested.connect(self.__ui_connection_worker.apply_node_settings) + self._signal_node_save_custom_settings_requested.connect(self.__ui_connection_worker.node_save_custom_settings) + self._signal_node_set_settings_default_requested.connect(self.__ui_connection_worker.node_set_settings_default) + self._signal_nodetypes_update_requested.connect(self.__ui_connection_worker.get_nodetypes) + self._signal_nodepresets_update_requested.connect(self.__ui_connection_worker.get_nodepresets) + self._signal_nodepreset_requested.connect(self.__ui_connection_worker.get_nodepreset) + self._signal_set_node_name_requested.connect(self.__ui_connection_worker.set_node_name) + self._signal_create_node_requested.connect(self.__ui_connection_worker.create_node) + self._signal_remove_nodes_requested.connect(self.__ui_connection_worker.remove_nodes) + self._signal_wipe_node_requested.connect(self.__ui_connection_worker.wipe_node) + self._signal_duplicate_nodes_requested.connect(self.__ui_connection_worker.duplicate_nodes) + self._signal_change_node_connection_requested.connect(self.__ui_connection_worker.change_node_connection) + self._signal_remove_node_connections_requested.connect(self.__ui_connection_worker.remove_node_connections) + self._signal_add_node_connection_requested.connect(self.__ui_connection_worker.add_node_connection) + self._signal_set_task_state.connect(self.__ui_connection_worker.set_task_state) + self._signal_set_tasks_paused.connect(self.__ui_connection_worker.set_tasks_paused) + self._signal_set_task_group_state_requested.connect(self.__ui_connection_worker.set_task_group_archived_state) + self._signal_set_task_group_filter.connect(self.__ui_connection_worker.set_task_group_filter) + self._signal_set_task_node_requested.connect(self.__ui_connection_worker.set_task_node) + self._signal_set_task_name_requested.connect(self.__ui_connection_worker.set_task_name) + self._signal_set_task_groups_requested.connect(self.__ui_connection_worker.set_task_groups) + self._signal_update_task_attributes_requested.connect(self.__ui_connection_worker.update_task_attributes) + self._signal_cancel_task_requested.connect(self.__ui_connection_worker.cancel_task) + self._signal_add_task_requested.connect(self.__ui_connection_worker.add_task) + self._signal_task_invocation_job_requested.connect(self.__ui_connection_worker.get_task_invocation_job) + self._signal_set_skip_dead.connect(self.__ui_connection_worker.set_skip_dead) + self._signal_set_skip_archived_groups.connect(self.__ui_connection_worker.set_skip_archived_groups) + self._signal_set_environment_resolver_arguments.connect(self.__ui_connection_worker.set_environment_resolver_arguments) + self._signal_unset_environment_resolver_arguments.connect(self.__ui_connection_worker.unset_environment_resolver_arguments) + # + self._signal_poke_graph_and_tasks_update.connect(self.__ui_connection_worker.poke_graph_and_tasks_update) + self._signal_poke_task_groups_update.connect(self.__ui_connection_worker.poke_task_groups_update) + self._signal_poke_workers_update.connect(self.__ui_connection_worker.poke_workers_update) + + def request_log(self, invocation_id: int, operation_data: Optional["LongOperationData"] = None): + self._signal_log_has_been_requested.emit(invocation_id, operation_data) + + def request_log_meta(self, task_id: int, operation_data: Optional["LongOperationData"] = None): + self._signal_log_meta_has_been_requested.emit(task_id, operation_data) + + def request_attributes(self, task_id: int, operation_data: Optional["LongOperationData"] = None): + self._signal_task_ui_attributes_has_been_requested.emit(task_id, operation_data) + + def request_invocation_job(self, task_id: int): + self._signal_task_invocation_job_requested.emit(task_id) + + def request_node_ui(self, node_id: int): + self._signal_node_ui_has_been_requested.emit(node_id) + + def query_node_has_parameter(self, node_id: int, param_name: str, operation_data: Optional["LongOperationData"] = None): + self._signal_node_has_parameter_requested.emit(node_id, param_name, operation_data) + + def request_node_parameter_change(self, node_id: int, param: Parameter, operation_data: Optional["LongOperationData"] = None): + self._signal_node_parameter_change_requested.emit(node_id, param, operation_data) + + def request_node_parameter_expression_change(self, node_id: int, param: Parameter, operation_data: Optional["LongOperationData"] = None): + self._signal_node_parameter_expression_change_requested.emit(node_id, [param], operation_data) + + def request_node_parameters_change(self, node_id: int, params: Iterable[Parameter], operation_data: Optional["LongOperationData"] = None): + self._signal_node_parameters_change_requested.emit(node_id, params, operation_data) + + def request_apply_node_settings(self, node_id: int, settings_name: str, operation_data: Optional["LongOperationData"] = None): + self._signal_node_apply_settings_requested.emit(node_id, settings_name, operation_data) + + def request_save_custom_settings(self, node_type_name: str, settings_name: str, settings: dict, operation_data: Optional["LongOperationData"] = None): + self._signal_node_save_custom_settings_requested.emit(node_type_name, settings_name, settings, operation_data) + + def request_set_settings_default(self, node_type_name: str, settings_name: Optional[str], operation_data: Optional["LongOperationData"] = None): + self._signal_node_set_settings_default_requested.emit(node_type_name, settings_name, operation_data) + + def request_node_types_update(self): + self._signal_nodetypes_update_requested.emit() + + def request_node_presets_update(self): + self._signal_nodepresets_update_requested.emit() + + def request_node_preset(self, packagename: str, presetname: str, operation_data: Optional["LongOperationData"] = None): + self._signal_nodepreset_requested.emit(packagename, presetname, operation_data) + + def request_set_node_name(self, node_id: int, name: str, operation_data: Optional["LongOperationData"] = None): + self._signal_set_node_name_requested.emit(node_id, name, operation_data) + + def request_node_connection_change(self, connection_id: int, outnode_id: Optional[int] = None, outname: Optional[str] = None, innode_id: Optional[int] = None, inname: Optional[str] = None): + self._signal_change_node_connection_requested.emit(connection_id, outnode_id, outname, innode_id, inname) + + def request_node_connection_remove(self, connection_id: int, operation_data: Optional["LongOperationData"] = None): + self._signal_remove_node_connections_requested.emit([connection_id], operation_data) + + def request_node_connection_add(self, outnode_id: int, outname: str, innode_id: int, inname: str, operation_data: Optional["LongOperationData"] = None): + self._signal_add_node_connection_requested.emit(outnode_id, outname, innode_id, inname, operation_data) + + def request_create_node(self, typename: str, nodename: str, pos: QPointF, operation_data: Optional["LongOperationData"] = None): + self._signal_create_node_requested.emit(typename, nodename, pos, operation_data) + + def request_remove_node(self, node_id: int, operation_data: Optional["LongOperationData"] = None): + if operation_data is None: + node = self.get_node(node_id) + if node is not None: + self.__node_snapshots[node_id] = UiNodeSnippetData.from_viewer_nodes([node]) + self._signal_remove_nodes_requested.emit([node_id], operation_data) + + def request_remove_nodes(self, node_ids: List[int], operation_data: Optional["LongOperationData"] = None): + if operation_data is None: + for node_id in node_ids: + node = self.get_node(node_id) + if node is not None: + self.__node_snapshots[node_id] = UiNodeSnippetData.from_viewer_nodes([node]) + self._signal_remove_nodes_requested.emit(node_ids, operation_data) + + def request_wipe_node(self, node_id: int): + self._signal_wipe_node_requested.emit(node_id) + + def request_duplicate_nodes(self, node_ids: List[int], shift: QPointF): + self._signal_duplicate_nodes_requested.emit(node_ids, shift) + + def set_task_group_filter(self, groups): + self._signal_set_task_group_filter.emit(groups) + + def set_task_state(self, task_ids: List[int], state: TaskState): + self._signal_set_task_state.emit(task_ids, state) + + def set_tasks_paused(self, task_ids_or_groups: List[Union[int, str]], paused: bool): + if all(isinstance(x, int) for x in task_ids_or_groups): + self._signal_set_tasks_paused.emit(task_ids_or_groups, paused) + else: + for tid_or_group in task_ids_or_groups: + self._signal_set_tasks_paused.emit(tid_or_group, paused) + + def set_task_group_archived_state(self, group_names: List[str], state: TaskGroupArchivedState): + for group_name in group_names: + self._signal_set_task_group_state_requested.emit(group_name, state) + + def request_task_cancel(self, task_id: int): + self._signal_cancel_task_requested.emit(task_id) + + def request_set_task_node(self, task_id: int, node_id: int): + self._signal_set_task_node_requested.emit(task_id, node_id) + + def request_add_task(self, new_task: NewTask): + self._signal_add_task_requested.emit(new_task) + + def request_rename_task(self, task_id: int, new_name: str): + self._signal_set_task_name_requested.emit(task_id, new_name) + + def request_set_task_groups(self, task_id: int, new_groups: Set[str]): + self._signal_set_task_groups_requested.emit(task_id, new_groups) + + def request_update_task_attributes(self, task_id: int, attribs_to_update: dict, attribs_to_delete: Set[str]): + self._signal_update_task_attributes_requested.emit(task_id, attribs_to_update, attribs_to_delete) + + def set_skip_dead(self, do_skip: bool) -> None: + self._signal_set_skip_dead.emit(do_skip) + + def set_skip_archived_groups(self, do_skip: bool) -> None: + self._signal_set_skip_archived_groups.emit(do_skip) + + def request_set_environment_resolver_arguments(self, task_id, env_args): + self._signal_set_environment_resolver_arguments.emit(task_id, env_args) + + def request_unset_environment_resolver_arguments(self, task_id): + self._signal_unset_environment_resolver_arguments.emit(task_id) + + # + + def request_graph_and_tasks_update(self): + """ + send a request to the scheduler to update node graph and tasks state immediately + """ + self._signal_poke_graph_and_tasks_update.emit() + + def request_task_groups_update(self): + """ + send a request to the scheduler to update task groups state immediately + """ + self._signal_poke_task_groups_update.emit() + + def request_workers_update(self): + """ + send a request to the scheduler to update workers state immediately + """ + self._signal_poke_workers_update.emit() + + # + # Higher-level request functions: + + def regenerate_all_ready_tasks_for_node(self, node_id: int): + """ + all currently displayed tasks that are in states BEFORE invoking/in-progress, will be set to WAITING + """ + self._change_all_task_states_for_node(node_id, (TaskState.READY, TaskState.WAITING_BLOCKED), TaskState.WAITING) + + def retry_all_error_tasks_for_node(self, node_id: int): + """ + all currently displayed task that are in ERROR state will be reset to WAITING + """ + self._change_all_task_states_for_node(node_id, (TaskState.ERROR,), TaskState.WAITING) + + def _change_all_task_states_for_node(self, node_id: int, from_states: Tuple[TaskState, ...], to_state: TaskState): + node = self.get_node(node_id) + self.set_task_state( + [x.get_id() for x in node.tasks_iter() if x.state() in from_states], + to_state + ) + # + # + + def skip_dead(self) -> bool: + return self.__ui_connection_worker.skip_dead() # should be fine and thread-safe in eyes of python + + def skip_archived_groups(self) -> bool: + return self.__ui_connection_worker.skip_archived_groups() # should be fine and thread-safe in eyes of python + + # + + def _nodes_were_moved(self, nodes_datas: Sequence[Tuple[Node, QPointF]]): + """ + item needs to notify the scene that move operation has happened, + scene needs to create an undo entry for that + """ + + op = MoveNodesOp(self, + ((node, node.pos(), old_pos) for node, old_pos in nodes_datas) + ) + op.do() + + def node_position(self, node_id: int): + if self.__db_path is not None: + if self.__nodes_table_name is None: + raise RuntimeError('node positions requested before db uid set') + with sqlite3.connect(self.__db_path) as con: + con.row_factory = sqlite3.Row + cur = con.execute(f'SELECT * FROM "{self.__nodes_table_name}" WHERE "id" = ?', (node_id,)) + row = cur.fetchone() + if row is not None: + return row['posx'], row['posy'] + + raise ValueError(f'node id {node_id} has no stored position') + + def set_node_position(self, node_id: int, pos: Union[Tuple[float, float], QPointF]): + if isinstance(pos, QPointF): + pos = pos.toTuple() + if self.__db_path is not None: + if self.__nodes_table_name is None: + raise RuntimeError('node positions requested before db uid set') + with sqlite3.connect(self.__db_path) as con: + con.row_factory = sqlite3.Row + cur = con.execute(f'INSERT INTO "{self.__nodes_table_name}" ("id", "posx", "posy") VALUES (?, ?, ?) ON CONFLICT("id") DO UPDATE SET posx = ?, posy = ?', (node_id, *pos, *pos)) + row = cur.fetchone() + if row is not None: + return row['posx'], row['posy'] + + def node_types(self) -> MappingProxyType[str, NodeTypeMetadata]: + return MappingProxyType(self.__cached_nodetypes) + + # + # async operations + # + + def create_node(self, typename: str, nodename: str, pos: QPointF, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): + op = CreateNodeOp(self, self, typename, nodename, pos) + op.do(callback) + + def delete_selected_nodes(self, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): + nodes: List[Node] = [] + for item in self.selectedItems(): + if isinstance(item, Node): + nodes.append(item) + if not nodes: + return + + op = RemoveNodesOp(self, self, nodes) + op.do(callback) + + def add_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): + outnode = self.get_node(outnode_id) + innode = self.get_node(innode_id) + + op = AddConnectionOp(self, self, outnode, outname, innode, inname) + op.do(callback) + + def cut_connection(self, outnode_id: int, outname: str, innode_id: int, inname: str, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): + outnode = self.get_node(outnode_id) + innode = self.get_node(innode_id) + + op = RemoveConnectionOp(self, self, outnode, outname, innode, inname) + op.do(callback) + + def cut_connection_by_id(self, con_id, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): + con = self.get_node_connection(con_id) + if con is None: + return + cin = con.input() + cout = con.output() + + return self.cut_connection(cout[0].get_id(), cout[1], cin[0].get_id(), cin[1], callback=callback) + + def change_connection(self, from_outnode_id: int, from_outname: str, from_innode_id: int, from_inname: str, *, + to_outnode_id: Optional[int] = None, to_outname: Optional[str] = None, + to_innode_id: Optional[int] = None, to_inname: Optional[str] = None, + callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): + # TODO: make proper ChangeConnectionOp + from_outnode = self.get_node(from_outnode_id) + from_innode = self.get_node(from_innode_id) + to_outnode = self.get_node(to_outnode_id) if to_outnode_id is not None else None + to_innode = self.get_node(to_innode_id) if to_innode_id is not None else None + + op1 = RemoveConnectionOp(self, self, from_outnode, from_outname, from_innode, from_inname) + op2 = AddConnectionOp(self, self, to_outnode or from_outnode, to_outname or from_outname, + to_innode or from_innode, to_inname or from_inname) + + op = CompoundAsyncSceneOperation(self, (op1, op2)) + op.do(callback) + + def change_connection_by_id(self, con_id, *, + to_outnode_id: Optional[int] = None, to_outname: Optional[str] = None, + to_innode_id: Optional[int] = None, to_inname: Optional[str] = None, + callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): + con = self.get_node_connection(con_id) + if con is None: + return + cin = con.input() + cout = con.output() + + return self.change_connection(cout[0].get_id(), cout[1], cin[0].get_id(), cin[1], + to_outnode_id=to_outnode_id, to_outname=to_outname, + to_innode_id=to_innode_id, to_inname=to_inname, + callback=callback) + + def change_node_parameter(self, node_id: int, item: Parameter, value: Any = ..., expression=..., + *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): + """ + + :param node_id: + :param item: + :param value: ... means no change + :param expression: ... means no change + :param callback: optional callback to call on successful completion of async operation + :return: + """ + logger.debug(f'node:{node_id}, changing "{item.name()}" to {repr(value)}/({expression})') + node_sid = self._session_node_id_from_id(node_id) + op = ParameterChangeOp(self, self, self.get_node(node_id), item.name(), value, expression) + op.do(callback) + + def rename_node(self, node_id: int, new_name: str, *, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): + node = self.get_node(node_id) + if node is None: + logger.warning(f'cannot move node: node not found') + + op = RenameNodeOp(self, self, node, new_name) + op.do(callback) + + # + + def fetch_log_run_callback(self, invocation_id, callback: Callable[[InvocationLogData, Any], None], callback_data: Any = None): + """ + fetch log for given invocation and run callback + + callback is run only in case of success + """ + def _fetch_open_log_longop(longop: LongOperation): + longop.set_op_status(None, f"fetching log for {invocation_id}") + self.request_log(invocation_id, LongOperationData(longop)) + _, logss = yield # type: int, Dict[int, Dict[int, InvocationLogData]] + if len(logss) == 0: + logger.error(f'could not find logs for {invocation_id}') + return + elif len(logss) > 1: + logger.error(f'unexpected error! {invocation_id} returned multiple nodes: {list(logss.keys())}') + return + logs = list(logss.values())[0] # expect single entry in logs + if invocation_id not in logs: + logger.error(f'could not find logs for {invocation_id}') + return + log: InvocationLogData = logs[invocation_id] + + if callback: + callback(log, callback_data) + + self.add_long_operation(_fetch_open_log_longop) + + # + # scheduler update events + # + + @Slot(object) + def db_uid_update(self, new_db_uid: int): + if self.__db_uid is not None and self.__db_uid != new_db_uid: + logger.info('scheduler\'s database changed. resetting the view...') + self.save_node_layout() + self.clear() + self.__db_uid = None + self.__nodes_table_name = None + self.reset_undo_stack() + # this means we probably reconnected to another scheduler, so existing nodes need to be dropped + + if self.__db_uid is None: + self.__db_uid = new_db_uid + self.__nodes_table_name = f'nodes_{self.__db_uid}' + with sqlite3.connect(self.__db_path) as con: + con.executescript(sql_init_script_nodes.format(db_uid=self.__db_uid)) + self.reset_undo_stack() + + @timeit(0.05) + @Slot(object) + def graph_full_update(self, graph_data: NodeGraphStructureData): + if self.__db_uid != graph_data.db_uid: + logger.warning(f'received node graph update with a differend db uid. Maybe a ghost if scheduler has just switched db. Ignoring. expect: {self.__db_uid}, got: {graph_data.db_uid}') + return + + to_del = [] + existing_node_ids: Dict[int, Node] = {} + existing_conn_ids: Dict[int, NodeConnection] = {} + + for item in self.items(): + if isinstance(item, Node): + if item.get_id() not in graph_data.nodes or item.node_type() != graph_data.nodes[item.get_id()].type: + to_del.append(item) + continue + existing_node_ids[item.get_id()] = item + elif isinstance(item, NodeConnection): + if item.get_id() not in graph_data.connections: + to_del.append(item) + continue + existing_conn_ids[item.get_id()] = item + print('---') + print(existing_node_ids) + print('---') + + # delete things + for item in to_del: + self.removeItem(item) + + # removing items might cascade things, like removing node will remove connections to that node + # so now we need to recheck existing items validity + for existings in (existing_node_ids, existing_conn_ids): + for item_id, item in tuple(existings.items()): + if item.scene() != self: + del existings[item_id] + + # create new nodes, update node names (node parameters are NOT part of graph data) + nodes_to_layout = [] + for id, new_node_data in graph_data.nodes.items(): + if id in existing_node_ids: + existing_node_ids[id].set_name(new_node_data.name) + continue + new_node = self.__scene_item_factory.make_node(self, id, new_node_data.type, new_node_data.name or f'node #{id}') + try: + new_node.setPos(*self.node_position(id)) + except ValueError: + nodes_to_layout.append(new_node) + existing_node_ids[id] = new_node + self.addItem(new_node) + + # now check if there are task updates that we received before node updates + for task_id, node_id in self.__tasks_to_try_reparent_during_node_update.items(): + if node_id in existing_node_ids: + task = self.get_task(task_id) + if task is None: # may has already been deleted by another tasks update + continue + existing_node_ids[node_id].add_task(task) + else: + task = self.get_task(task_id) + logger.warning(f'could not find node_id {node_id} for an orphaned during update task {task_id} ({task})') + + # + # add connections + for id, new_conn_data in graph_data.connections.items(): + if id in existing_conn_ids: + # ensure connections + innode, inname = existing_conn_ids[id].input() + outnode, outname = existing_conn_ids[id].output() + if innode.get_id() != new_conn_data.in_id or inname != new_conn_data.in_name: + existing_conn_ids[id].set_input(existing_node_ids[new_conn_data.in_id], new_conn_data.in_name) + existing_conn_ids[id].update() + if outnode.get_id() != new_conn_data.out_id or outname != new_conn_data.out_name: + existing_conn_ids[id].set_output(existing_node_ids[new_conn_data.out_id], new_conn_data.out_name) + existing_conn_ids[id].update() + continue + new_conn = self.__scene_item_factory.make_node_connection( + self, + id, + existing_node_ids[new_conn_data.out_id], + existing_node_ids[new_conn_data.in_id], + new_conn_data.out_name, new_conn_data.in_name + ) + existing_conn_ids[id] = new_conn + self.addItem(new_conn) + + if nodes_to_layout: + self.layout_nodes(nodes_to_layout) + + print('+++') + print(self.items()) + print(self.nodes()) + + @timeit(0.05) + @Slot(object, bool) + def tasks_process_events(self, events: List[TaskEvent], first_time_getting_events: bool): + """ + + :param events: + :param first_time_getting_events: True if it's a first event batch since filter change + :return: + """ + for event in events: + logger.debug(f'event: {event.tiny_repr()}') + if event.database_uid != self.__db_uid: + logger.warning(f'received event with a differend db uid. Maybe a ghost if scheduler has just switched db. Ignoring. expect: {self.__db_uid}, got: {event.database_uid}') + continue + + if isinstance(event, TaskFullState): + self.tasks_full_update(event.task_data) + elif isinstance(event, TasksUpdated): + self.tasks_update(event.task_data) + elif isinstance(event, TasksChanged): + self.tasks_deltas_apply(event.task_deltas) #, animated=not first_time_getting_events) + elif isinstance(event, TasksRemoved): + existing_tasks = dict(self.tasks_dict()) + for task_id in event.task_ids: + if task_id in existing_tasks: + self.removeItem(existing_tasks[task_id]) + existing_tasks.pop(task_id) + + def tasks_deltas_apply(self, task_deltas: List[TaskDelta]): + for task_delta in task_deltas: + task_id = task_delta.id + task = self.get_task(task_id) + if task is None: # this would be unusual + logger.warning(f'cannot apply task delta: task {task_id} does not exist') + continue + if task_delta.node_id is not DataNotSet: + node = self.get_node(task_delta.node_id) + if node is None: + logger.warning('node not found during task delta processing, this will probably be fixed during next update') + self.__tasks_to_try_reparent_during_node_update[task_id] = task_delta.node_id + task.apply_task_delta(task_delta, self.get_node) + + @timeit(0.05) + @Slot(object) + def tasks_full_update(self, tasks_data: TaskBatchData): + if self.__db_uid != tasks_data.db_uid: + logger.warning(f'received node graph update with a differend db uid. Maybe a ghost if scheduler has just switched db. Ignoring. expect: {self.__db_uid}, got: {tasks_data.db_uid}') + return + + to_del = [] + to_del_tasks = {} + existing_task_ids: Dict[int, Task] = {} + + for item in self.tasks(): + if item.get_id() not in tasks_data.tasks: + to_del.append(item) + if item.node() is not None: + if not item.node() in to_del_tasks: + to_del_tasks[item.node()] = [] + to_del_tasks[item.node()].append(item) + continue + existing_task_ids[item.get_id()] = item + + for node, tasks in to_del_tasks.items(): + node.remove_tasks(tasks) + + for item in to_del: + self.removeItem(item) + + # we don't need that cuz scene already takes care of upkeeping task dict + # # removing items might cascade things, like removing node will remove connections to that node + # # so now we need to recheck existing items validity + # # though not consistent scene states should not come in uidata at all + # for item_id, item in tuple(existing_task_ids.items()): + # if item.scene() != self: + # del existing_task_ids[item_id] + + self.tasks_update(tasks_data) + + def tasks_update(self, tasks_data: TaskBatchData): + """ + unlike tasks_full_update - this ONLY applies updates, does not delete anything + + :param tasks_data: + :param existing_tasks: optional already computed dict of existing tasks. if none - it will be computed + :return: + """ + + existing_tasks = dict(self.tasks_dict()) + + for id, new_task_data in tasks_data.tasks.items(): + if id not in existing_tasks: + new_task = self.__scene_item_factory.make_task(self, new_task_data) + existing_tasks[id] = new_task + if new_task_data.split_origin_task_id is not None and new_task_data.split_origin_task_id in existing_tasks: # TODO: bug: this and below will only work if parent/original tasks were created during previous updates + origin_task = existing_tasks[new_task_data.split_origin_task_id] + new_task.setPos(origin_task.scenePos()) + elif new_task_data.parent_id is not None and new_task_data.parent_id in existing_tasks: + origin_task = existing_tasks[new_task_data.parent_id] + new_task.setPos(origin_task.scenePos()) + self.addItem(new_task) + task = existing_tasks[id] + existing_node = self.get_node(new_task_data.node_id) + if existing_node: + existing_node.add_task(task) + else: + self.__tasks_to_try_reparent_during_node_update[id] = new_task_data.node_id + task.set_task_data(new_task_data) + + # @timeit(0.05) + # @Slot(object) + # def full_update(self, uidata: UiData): + # raise DeprecationWarning('no use') + # # logger.debug('full_update') + # + # if self.__db_uid is not None and self.__db_uid != uidata.db_uid: + # logger.info('scheduler\'s database changed. resetting the view...') + # self.save_node_layout() + # self.clear() + # self.__db_uid = None + # self.__nodes_table_name = None + # # this means we probably reconnected to another scheduler, so existing nodes need to be dropped + # + # if self.__db_uid is None: + # self.__db_uid = uidata.db_uid + # self.__nodes_table_name = f'nodes_{self.__db_uid}' + # with sqlite3.connect(self.__db_path) as con: + # con.executescript(sql_init_script_nodes.format(db_uid=self.__db_uid)) + # + # to_del = [] + # to_del_tasks = {} + # existing_node_ids: Dict[int, Node] = {} + # existing_conn_ids: Dict[int, NodeConnection] = {} + # existing_task_ids: Dict[int, Task] = {} + # _perf_total = 0.0 + # graph_data = uidata.graph_data + # with performance_measurer() as pm: + # for item in self.items(): + # if isinstance(item, Node): # TODO: unify this repeating code and move the setting attribs to after all elements are created + # if item.get_id() not in graph_data.nodes or item.node_type() != graph_data.nodes[item.get_id()].type: + # to_del.append(item) + # continue + # existing_node_ids[item.get_id()] = item + # # TODO: update all kind of attribs here, for now we just don't have any + # elif isinstance(item, NodeConnection): + # if item.get_id() not in graph_data.connections: + # to_del.append(item) + # continue + # existing_conn_ids[item.get_id()] = item + # # TODO: update all kind of attribs here, for now we just don't have any + # elif isinstance(item, Task): + # if item.get_id() not in uidata.tasks.tasks: + # to_del.append(item) + # if item.node() is not None: + # if not item.node() in to_del_tasks: + # to_del_tasks[item.node()] = [] + # to_del_tasks[item.node()].append(item) + # continue + # existing_task_ids[item.get_id()] = item + # _perf_item_classify = pm.elapsed() + # _perf_total += pm.elapsed() + # + # # before we delete everything - we'll remove tasks from nodes to avoid deleting tasks one by one triggering tonns of animation + # with performance_measurer() as pm: + # for node, tasks in to_del_tasks.items(): + # node.remove_tasks(tasks) + # _perf_remove_tasks = pm.elapsed() + # _perf_total += pm.elapsed() + # with performance_measurer() as pm: + # for item in to_del: + # self.removeItem(item) + # _perf_remove_items = pm.elapsed() + # _perf_total += pm.elapsed() + # # removing items might cascade things, like removing node will remove connections to that node + # # so now we need to recheck existing items validity + # # though not consistent scene states should not come in uidata at all + # with performance_measurer() as pm: + # for existings in (existing_node_ids, existing_task_ids, existing_conn_ids): + # for item_id, item in tuple(existings.items()): + # if item.scene() != self: + # del existings[item_id] + # _perf_revalidate = pm.elapsed() + # _perf_total += pm.elapsed() + # + # nodes_to_layout = [] + # with performance_measurer() as pm: + # for id, new_node_data in graph_data.nodes.items(): + # if id in existing_node_ids: + # existing_node_ids[id].set_name(new_node_data.name) + # continue + # new_node = self.__scene_item_factory.make_node(self, id, new_node_data.type, new_node_data.name or f'node #{id}') + # try: + # new_node.setPos(*self.node_position(id)) + # except ValueError: + # nodes_to_layout.append(new_node) + # existing_node_ids[id] = new_node + # self.addItem(new_node) + # _perf_create_nodes = pm.elapsed() + # _perf_total += pm.elapsed() + # + # with performance_measurer() as pm: + # for id, new_conn_data in graph_data.connections.items(): + # if id in existing_conn_ids: + # # ensure connections + # innode, inname = existing_conn_ids[id].input() + # outnode, outname = existing_conn_ids[id].output() + # if innode.get_id() != new_conn_data.in_id or inname != new_conn_data.in_name: + # existing_conn_ids[id].set_input(existing_node_ids[new_conn_data.in_id], new_conn_data.in_name) + # existing_conn_ids[id].update() + # if outnode.get_id() != new_conn_data.out_id or outname != new_conn_data.out_name: + # existing_conn_ids[id].set_output(existing_node_ids[new_conn_data.out_id], new_conn_data.out_name) + # existing_conn_ids[id].update() + # continue + # new_conn = self.__scene_item_factory.make_node_connection( + # self, + # id, + # existing_node_ids[new_conn_data.out_id], + # existing_node_ids[new_conn_data.in_id], + # new_conn_data.out_name, new_conn_data.in_name + # ) + # existing_conn_ids[id] = new_conn + # self.addItem(new_conn) + # _perf_create_connections = pm.elapsed() + # _perf_total += pm.elapsed() + # + # with performance_measurer() as pm: + # for id, new_task_data in uidata.tasks.tasks.items(): + # if id not in existing_task_ids: + # new_task = self.__scene_item_factory.make_task(self, new_task_data) + # existing_task_ids[id] = new_task + # if new_task_data.split_origin_task_id is not None and new_task_data.split_origin_task_id in existing_task_ids: # TODO: bug: this and below will only work if parent/original tasks were created during previous updates + # origin_task = existing_task_ids[new_task_data.split_origin_task_id] + # new_task.setPos(origin_task.scenePos()) + # elif new_task_data.parent_id is not None and new_task_data.parent_id in existing_task_ids: + # origin_task = existing_task_ids[new_task_data.parent_id] + # new_task.setPos(origin_task.scenePos()) + # self.addItem(new_task) + # task = existing_task_ids[id] + # existing_node_ids[new_task_data.node_id].add_task(task) + # task.set_task_data(new_task_data) + # _perf_create_tasks = pm.elapsed() + # _perf_total += pm.elapsed() + # + # # now layout nodes that need it + # with performance_measurer() as pm: + # if nodes_to_layout: + # self.layout_nodes(nodes_to_layout) + # _perf_layout = pm.elapsed() + # _perf_total += pm.elapsed() + # + # with performance_measurer() as pm: + # if self.__all_task_groups != uidata.task_groups: + # self.__all_task_groups = uidata.task_groups + # self.task_groups_updated.emit(uidata.task_groups) + # _perf_task_groups_update = pm.elapsed() + # _perf_total += pm.elapsed() + # + # if _perf_total > 0.04: # arbitrary threshold ~ 1/25 of a sec + # logger.debug(f'update performed:\n' + # f'{_perf_item_classify:.04f}:\tclassify\n' + # f'{_perf_remove_tasks:.04f}:\tremove tasks\n' + # f'{_perf_remove_items:.04f}:\tremove items\n' + # f'{_perf_revalidate:.04f}:\trevalidate\n' + # f'{_perf_create_nodes:.04f}:\tcreate nodes\n' + # f'{_perf_create_connections:.04f}:\tcreate connections\n' + # f'{_perf_create_tasks:.04f}:\tcreate tasks\n' + # f'{_perf_layout:.04f}:\tlayout\n' + # f'{_perf_task_groups_update:.04f}:\ttask group update') + + @Slot(object, object, bool, object) + def log_fetched(self, task_id: int, log: Dict[int, Dict[int, Union[IncompleteInvocationLogData, InvocationLogData]]], full_update, data: Optional["LongOperationData"] = None): + """ + this slot to be connected to data provider, whenever log is fetched - this method should be called + + full_update is true, if log dict covers all invocations. + otherwise update is considered partial, so only updated information counts, no removes are to be done + """ + if task_id >= 0: # otherwise it means empty event + task = self.get_task(task_id) + if task is None: + logger.warning(f'log fetched, but task not found! {task_id}') + else: + task.update_log(log, full_update) + + if data is not None: + data.data = (task_id, log) + self.process_operation(data) + + @Slot(object, object) + def nodeui_fetched(self, node_id: int, nodeui: NodeUi): + node = self.get_node(node_id) + if node is None: + logger.warning('node ui fetched for non existant node') + return + node.update_nodeui(nodeui) + + @Slot(object, object, object) + def _task_attribs_fetched(self, task_id: int, all_attribs: Tuple[dict, Optional[EnvironmentResolverArguments]], data: Optional["LongOperationData"] = None): + task = self.get_task(task_id) + attribs, env_attribs = all_attribs + if task is None: + logger.warning('attribs fetched, but task not found!') + else: + task.update_attributes(attribs) + task.set_environment_attributes(env_attribs) + if data is not None: + data.data = attribs + self.process_operation(data) + + @Slot(object, object) + def _task_invocation_job_fetched(self, task_id: int, invjob: InvocationJob): + self.task_invocation_job_fetched.emit(task_id, invjob) + + @Slot(int, str, bool, object) + def _node_has_parameter(self, node_id, param_name, exists, data: Optional["LongOperationData"] = None): + if data is not None: + data.data = (node_id, param_name, exists) + self.process_operation(data) + + @Slot(int, object, object, object) + def _node_parameter_changed(self, node_id, param, newval, data: Optional["LongOperationData"] = None): + if data is not None: + data.data = (node_id, param.name(), newval) + self.process_operation(data) + + @Slot(int, object, object, object) + def _node_parameters_changed(self, node_id, params, newvals, data: Optional["LongOperationData"] = None): + if data is not None: + data.data = (node_id, tuple(param.name() for param in params), newvals) + self.process_operation(data) + + @Slot(int, object, object) + def _node_parameter_expression_changed(self, node_id, param, data: Optional["LongOperationData"] = None): + if data is not None: + data.data = (node_id, param.name()) + self.process_operation(data) + + @Slot(int, object, object) + def _node_settings_applied(self, node_id, settings_name, data: Optional["LongOperationData"] = None): + node = self.get_node(node_id) + if node is not None: + self.request_node_ui(node_id) + if data is not None: + data.data = (node_id, settings_name) # TODO: add return status here? + self.process_operation(data) + + @Slot(str, str, object) + def _node_custom_settings_saved(self, type_name: str, settings_name: str, data: Optional["LongOperationData"] = None): + if data is not None: + data.data = (type_name, settings_name) # TODO: add return status here? + self.process_operation(data) + + @Slot(str, str, object) + def _node_default_settings_set(self, type_name: str, settings_name: Optional[str], data: Optional["LongOperationData"] = None): + if data is not None: + data.data = (type_name, settings_name) # TODO: add return status here? + self.process_operation(data) + + @Slot(int, str, str, object, object) + def _node_created(self, node_id, node_type, node_name, pos, data: Optional["LongOperationData"] = None): + node = self.__scene_item_factory.make_node(self, node_id, node_type, node_name) + node.setPos(pos) + self.addItem(node) + if data is not None: + data.data = (node_id, node_type, node_name) + self.process_operation(data) + + def _nodes_removed(self, node_ids: List[int], failed_node_ids: List[Tuple[int, str]], data: Optional["LongOperationData"] = None): + for node_id in node_ids: + node = self.get_node(node_id) + if node is not None: + self.removeItem(node) + if data is not None: + data.data = (node_ids, failed_node_ids) + self.process_operation(data) + + def _node_renamed(self, node_id: int, new_name: str, data: Optional["LongOperationData"] = None): + node = self.get_node(node_id) + if node is not None: + old_name = node.node_name() + node.set_name(new_name) + if data is not None: + data.data = (node_id, new_name) + self.process_operation(data) + + @Slot(object, object) + def _nodes_duplicated(self, old_to_new: Dict[int, int], shift: QPointF): + for old_id, new_id in old_to_new.items(): + old_pos = QPointF() + old_node = self.get_node(old_id) + if old_node is not None: + old_pos = old_node.pos() + self.set_node_position(new_id, old_pos + shift) + + @Slot(list, object) + def _node_connections_removed(self, con_ids: List[int], failed_con_ids: List[Tuple[int, str]], data: Optional["LongOperationData"] = None): + for con_id in con_ids: + con = self.get_node_connection(con_id) + if con is not None: + self.removeItem(con) + if data is not None: + data.data = (con_ids, failed_con_ids) + self.process_operation(data) + + @Slot(list, object) + def _node_connections_added(self, cons: List[Tuple[int, int, str, int, str]], data: Optional["LongOperationData"] = None): + for new_id, outnode_id, outname, innode_id, inname in cons: + outnode = self.get_node(outnode_id) + innode = self.get_node(innode_id) + if outnode is None or innode is None: + return + new_conn = self.__scene_item_factory.make_node_connection(self, new_id, outnode, innode, outname, inname) + self.addItem(new_conn) + if data is not None: + data.data = ([x[0] for x in cons],) + self.process_operation(data) + + @Slot(int, int, str) + def _node_task_set(self, task_id: int, node_id: int, error: Optional[str]): + # no need to do anything - task update will be polled by connection worker + if error is not None: + self.unhandled_error_happened.emit(error) + + @Slot(object) + def _nodetypes_fetched(self, nodetypes): + self.__cached_nodetypes = nodetypes + self.nodetypes_updated.emit(nodetypes) + + @Slot(object) + def _nodepresets_fetched(self, nodepresets: Dict[str, Dict[str, Union[NodeSnippetData, NodeSnippetDataPlaceholder]]]): + # here we receive just the list of names, no contents, so we dont just update dicts + for package, presets in nodepresets.items(): + self.__cached_nodepresets.setdefault(package, {}) + for preset_name, preset_meta in presets.items(): + if preset_name not in self.__cached_nodepresets[package]: + self.__cached_nodepresets[package][preset_name] = preset_meta + presets_set = set(presets) + keys_to_del = [] + for key in self.__cached_nodepresets[package]: + if key not in presets_set: + keys_to_del.append(key) + for key in keys_to_del: + del self.__cached_nodepresets[package][key] + + self.nodepresets_updated.emit(self.__cached_nodepresets) + # {(pack, label): snippet for pack, packpres in self.__cached_nodepresets.items() for label, snippet in packpres.items()} + + @Slot(object, object) + def _nodepreset_fetched(self, package: str, preset: str, snippet: NodeSnippetData, data: Optional["LongOperationData"] = None): + self.nodepreset_received.emit(package, preset, snippet) + if data is not None: + data.data = (package, preset, snippet) + self.process_operation(data) + + @Slot(NodeSnippetData, QPointF) + def nodes_from_snippet(self, snippet: NodeSnippetData, pos: QPointF, containing_long_op: Optional[LongOperation] = None): + op = CreateNodesOp(self, self, snippet, pos) + op.do() + + def request_create_nodes_from_snippet(self, snippet: NodeSnippetData, pos: QPointF, containing_long_op: Optional[LongOperation] = None): + def pasteop(longop): + + tmp_to_new: Dict[int, int] = {} + created_nodes = [] # select delayed to ensure it happens after all changes to parameters + + # for ui progress + total_elements = len(snippet.nodes_data) + len(snippet.connections_data) + current_element = 0 + opname = 'pasting nodes' + + for nodedata in snippet.nodes_data: + current_element += 1 + if total_elements > 1: + longop.set_op_status(current_element / (total_elements - 1), opname) + self.request_create_node(nodedata.type, nodedata.name, QPointF(*nodedata.pos) + pos - QPointF(*snippet.pos), LongOperationData(longop, None)) + # NOTE: there is currently no mechanism to ensure order of results when more than one things are requested + # from the same operation. So we request and wait things one by one + node_id, _, _ = yield + tmp_to_new[nodedata.tmpid] = node_id + created_nodes.append(node_id) + + # assign session ids to new nodes, prefer tmp ids from the snippet + if self._session_node_id_to_id(nodedata.tmpid) is None: # session id is free + self._session_node_update_session_id(nodedata.tmpid, node_id) + + proxy_params = [] + for param_name, param_data in nodedata.parameters.items(): + proxy_param = Parameter(param_name, None, param_data.type, param_data.uvalue) + if param_data.expr is not None: + proxy_param.set_expression(param_data.expr) + proxy_params.append(proxy_param) + self.request_node_parameters_change(node_id, proxy_params, LongOperationData(longop, None)) + yield + + for node_id in created_nodes: # selecting + self.get_node(node_id).setSelected(True) + + for conndata in snippet.connections_data: + current_element += 1 + if total_elements > 1: + longop.set_op_status(current_element / (total_elements - 1), opname) + + con_out = tmp_to_new.get(conndata.tmpout, self._session_node_id_to_id(conndata.tmpout)) + con_in = tmp_to_new.get(conndata.tmpin, self._session_node_id_to_id(conndata.tmpin)) + if con_out is None or con_in is None: + logger.warning('failed to create connection during snippet creation!') + continue + self.request_node_connection_add(con_out, conndata.out_name, + con_in, conndata.in_name, LongOperationData(longop)) + yield + + if total_elements > 1: + longop.set_op_status(1.0, opname) + if containing_long_op is not None: + self.process_operation(LongOperationData(containing_long_op, tuple(created_nodes))) + + self.clearSelection() + self.add_long_operation(pasteop) + + @Slot(str, str, dict) + def save_nodetype_settings(self, node_type_name: str, settings_name: str, settings: Dict[str, Any]): + def savesettingsop(longop): + self.request_save_custom_settings(node_type_name, settings_name, settings, longop.new_op_data()) + yield # wait for operation to complete + self.request_node_types_update() + + self.add_long_operation(savesettingsop) + + # + # query + # + + def get_node_by_session_id(self, node_session_id) -> Optional[Node]: + node_id = self._session_node_id_to_id(node_session_id) + if node_id is None: + return None + return self.get_node(node_id, None) + + def find_nodes_by_name(self, name: str, match_partly=False) -> Set[Node]: + if match_partly: + match_fn = lambda x, y: x in y + else: + match_fn = lambda x, y: x == y + matched = set() + for node in self.nodes(): + if match_fn(name, node.node_name()): + matched.add(node) + + return matched + + # + # + # + + def start(self): + if self.__ui_connection_thread is None: + return + self.__ui_connection_thread.start() + + def stop(self): + if self.__ui_connection_thread is None: + for meth in dir(self): # disconnect all signals from worker slots + if not meth.startswith('_signal_'): + continue + try: + getattr(self, meth).disconnect() + except RuntimeError as e: + logger.warning(f'error disconnecting signal {meth}: {e}') + + # disconnect from worker's signals too + self.__ui_connection_worker.disconnect(self) + return + # if thread is not none - means we created thread AND worker, so we manage them both + self.__ui_connection_worker.request_interruption() + self.__ui_connection_thread.exit() + self.__ui_connection_thread.wait() + + def save_node_layout(self): + if self.__db_path is None: + return + + nodes_to_save = [item for item in self.items() if isinstance(item, Node)] + if len(nodes_to_save) == 0: + return + if self.__db_uid is None: + logger.warning('db uid is not set while saving nodes') + + if self.__nodes_table_name is None: + raise RuntimeError('node positions requested before db uid set') + + with sqlite3.connect(self.__db_path) as con: + con.row_factory = sqlite3.Row + for item in nodes_to_save: + con.execute(f'INSERT OR REPLACE INTO "{self.__nodes_table_name}" ("id", "posx", "posy") ' + f'VALUES (?, ?, ?)', (item.get_id(), *item.pos().toTuple())) + con.commit() + + def keyPressEvent(self, event: QKeyEvent) -> None: + for item in self.selectedItems(): + item.keyPressEvent(event) + event.accept() + # return super(QGraphicsImguiScene, self).keyPressEvent(event) + + def keyReleaseEvent(self, event: QKeyEvent) -> None: + for item in self.selectedItems(): + item.keyReleaseEvent(event) + event.accept() + # return super(QGraphicsImguiScene, self).keyReleaseEvent(event) + + # this will also catch accumulated events that wires ignore to determine the closest wire + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: + item_event_candidates: List[Tuple[float, QGraphicsItemExtended]] = [] + event.item_event_candidates = item_event_candidates + super(QGraphicsImguiSceneWithDataController, self).mousePressEvent(event) + logger.debug(f'press mouse grabber={self.mouseGrabberItem()}') + if not event.isAccepted() and len(event.item_event_candidates) > 0: + logger.debug('closest candidates: %s', ', '.join([str(x[0]) for x in event.item_event_candidates])) + closest = min(event.item_event_candidates, key=lambda x: x[0]) + closest[1].post_mousePressEvent(event) + elif not event.isAccepted() and self.mouseGrabberItem() is None: + logger.debug('probably started selecting') + self.__selection_happening = True + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: + super(QGraphicsImguiSceneWithDataController, self).mouseReleaseEvent(event) + logger.debug(f'release mouse grabber={self.mouseGrabberItem()}') + if not event.isAccepted() and self.mouseGrabberItem() is None and self.__selection_happening: + logger.debug('probably ended selecting') + self.__selection_happening = False + + def setSelectionArea(self, *args, **kwargs): + pass + + # + # layout + def layout_nodes(self, nodes: Optional[Iterable[Node]] = None, center: Optional[QPointF] = None): + if center is None: + center = QPointF(0, 0) + if nodes is None: + nodes = self.nodes() + + if not nodes: + return + + nodes_to_vertices = {x: grandalf.graphs.Vertex(x) for x in nodes} + graph = grandalf.graphs.Graph(nodes_to_vertices.values()) + lower_fixed = [] + upper_fixed = [] + + for node in nodes: + for output_name in node.output_names(): + for conn in node.output_connections(output_name): + nextnode, _ = conn.input() + if nextnode not in nodes_to_vertices and nextnode not in lower_fixed: + lower_fixed.append(nextnode) + if nextnode not in lower_fixed and nextnode not in upper_fixed: + graph.add_edge(grandalf.graphs.Edge(nodes_to_vertices[node], nodes_to_vertices[nextnode])) + for input_name in node.input_names(): + for conn in node.input_connections(input_name): + prevnode, _ = conn.output() + if prevnode not in nodes_to_vertices and prevnode not in upper_fixed: + upper_fixed.append(prevnode) + if prevnode not in lower_fixed and prevnode not in upper_fixed: + # double edges will be filtered by networkx, and we wont miss any connection to external nodes this way + graph.add_edge(grandalf.graphs.Edge(nodes_to_vertices[prevnode], nodes_to_vertices[node])) + + upper_middle_point = QPointF(0, float('inf')) + lower_middle_point = None + if len(lower_fixed) > 0: + for lower in lower_fixed: + upper_middle_point.setX(upper_middle_point.x() + lower.pos().x()) + upper_middle_point.setY(min(upper_middle_point.y(), lower.pos().y())) + upper_middle_point.setX(upper_middle_point.x() / len(lower_fixed)) + else: + upper_middle_point = center + + if len(upper_fixed) > 0: + lower_middle_point = QPointF(0, -float('inf')) + for upper in upper_fixed: + lower_middle_point.setX(lower_middle_point.x() + upper.pos().x()) + lower_middle_point.setY(max(lower_middle_point.y(), upper.pos().y())) + lower_middle_point.setX(lower_middle_point.x() / len(upper_fixed)) + + class _viewhelper: + def __init__(self, w, h): + self.w = w + self.h = h + + for node, vert in nodes_to_vertices.items(): + bounds = node.boundingRect() # type: QRectF + vert.view = _viewhelper(*bounds.size().toTuple()) + vert.view.h *= 1.5 + + vertices_to_nodes = {v: k for k, v in nodes_to_vertices.items()} + + xshift = 0 + nodewidgh = next(graph.V()).view.w # just take first for now + nodeheight = nodewidgh + upper_middle_point -= QPointF(0, 1.5 * nodeheight) + if lower_middle_point is not None: + lower_middle_point += QPointF(0, 1.5 * nodeheight) + # graph.C[0].layers[0].sV[0] + for component in graph.C: + layout = grandalf.layouts.SugiyamaLayout(component) + layout.init_all() + layout.draw() + + xmax = -float('inf') + ymax = -float('inf') + xmin = float('inf') + ymin = float('inf') + xshiftpoint = QPointF(xshift, 0) + for vertex in component.sV: + xmax = max(xmax, vertex.view.xy[0]) + ymax = max(ymax, vertex.view.xy[1]) + xmin = min(xmin, vertex.view.xy[0]) + ymin = min(ymin, vertex.view.xy[1]) + if len(lower_fixed) > 0 or lower_middle_point is None: + for vertex in component.sV: + vertices_to_nodes[vertex].setPos(QPointF(*vertex.view.xy) + xshiftpoint - QPointF((xmax + xmin) / 2, 0) + (upper_middle_point - QPointF(0, ymax))) + else: + for vertex in component.sV: + vertices_to_nodes[vertex].setPos(QPointF(*vertex.view.xy) + xshiftpoint - QPointF((xmax + xmin) / 2, 0) + (lower_middle_point - QPointF(0, ymin))) + xshift += (xmax - xmin) + 2 * nodewidgh + + # def layout_nodes(self, nodes: Optional[Iterable[Node]] = None): + # if nodes is None: + # nodes = self.nodes() + # + # nodes_set = set(nodes) + # graph = networkx.Graph() # wierdly digraph here works way worse for layout + # graph.add_nodes_from(nodes) + # fixed = [] + # for node in nodes: + # for output_name in node.output_names(): + # for conn in node.output_connections(output_name): + # nextnode, _ = conn.input() + # if nextnode not in nodes_set: + # nodes_set.add(nextnode) + # fixed.append(nextnode) + # graph.add_edge(node, nextnode) + # for input_name in node.input_names(): + # for conn in node.input_connections(input_name): + # prevnode, _ = conn.output() + # if prevnode not in nodes_set: + # nodes_set.add(prevnode) + # fixed.append(prevnode) + # # double edges will be filtered by networkx, and we wont miss any connection to external nodes this way + # graph.add_edge(prevnode, node) + # print(len(nodes_set), len(graph), len(fixed)) + # init_pos = {node: (node.pos()).toTuple() for node in nodes_set} + # print(graph) + # print(graph.edges) + # if not fixed: + # fixed.append(next(iter(nodes_set))) + # final_pos = networkx.drawing.layout.spring_layout(graph, 150, pos=init_pos, fixed=fixed or None, iterations=5) + # from pprint import pprint + # pprint(final_pos) + # for node, pos in final_pos.items(): + # node.setPos(QPointF(*pos)) diff --git a/src/lifeblood_viewer/lifeblood_viewer.py b/src/lifeblood_viewer/lifeblood_viewer.py index 167a98eb..d71fbdfc 100644 --- a/src/lifeblood_viewer/lifeblood_viewer.py +++ b/src/lifeblood_viewer/lifeblood_viewer.py @@ -9,7 +9,7 @@ from lifeblood.ui_protocol_data import TaskGroupBatchData, TaskGroupData from lifeblood import paths from .nodeeditor import NodeEditor -from .graphics_scene import QGraphicsImguiScene +from .graphics_scene_with_data_controller import QGraphicsImguiSceneWithDataController from .connection_worker import SchedulerConnectionWorker from .ui_scene_elements import FindNodePopup from .menu_entry_base import MainMenuLocation @@ -321,7 +321,7 @@ def _task_list_for_node(ne=self.__node_editor): # cOnNeC1 # TODO: Now that lifeblood_viewer owns connection worker - we may reconnect these in a more straight way... scene = self.__node_editor.scene() - assert isinstance(scene, QGraphicsImguiScene) + assert isinstance(scene, QGraphicsImguiSceneWithDataController) self.__ui_connection_worker.groups_full_update.connect(self.update_groups) self.__ui_connection_worker.scheduler_connection_lost.connect(self._show_connection_message) self.__ui_connection_worker.scheduler_connection_established.connect(self._hide_connection_message) diff --git a/src/lifeblood_viewer/nodeeditor.py b/src/lifeblood_viewer/nodeeditor.py index 2180f89f..80d7e4e2 100644 --- a/src/lifeblood_viewer/nodeeditor.py +++ b/src/lifeblood_viewer/nodeeditor.py @@ -6,7 +6,7 @@ from types import MappingProxyType from enum import Enum from .graphics_items import Task, Node, NetworkItem -from .graphics_scene import QGraphicsImguiScene +from .graphics_scene_with_data_controller import QGraphicsImguiSceneWithDataController from .long_op import LongOperation from .widgets.flashy_label import FlashyLabel from .ui_snippets import UiNodeSnippetData @@ -197,7 +197,7 @@ def __init__(self, db_path: str = None, worker=None, parent=None): # TODO: refactor this item_producer = FancySceneItemFactory(None) # TODO: split data controller from scene - self.__scene = QGraphicsImguiScene(item_producer, db_path, worker) + self.__scene = QGraphicsImguiSceneWithDataController(item_producer, db_path, worker) item_producer.set_data_controller(self.__scene) self.setScene(self.__scene) @@ -1148,7 +1148,7 @@ def release_ui_focus(self, item: NetworkItem): self.__ui_focused_item = None return True - def scene(self) -> QGraphicsImguiScene: # this function is here just for typing + def scene(self) -> QGraphicsImguiSceneWithDataController: # this function is here just for typing return super().scene() def mouseDoubleClickEvent(self, event: PySide2.QtGui.QMouseEvent): diff --git a/src/lifeblood_viewer/nodeeditor_overlays/overlay_base.py b/src/lifeblood_viewer/nodeeditor_overlays/overlay_base.py index 3e7c6f55..85208057 100644 --- a/src/lifeblood_viewer/nodeeditor_overlays/overlay_base.py +++ b/src/lifeblood_viewer/nodeeditor_overlays/overlay_base.py @@ -1,19 +1,19 @@ import re -from lifeblood_viewer.graphics_scene import QGraphicsImguiScene +from lifeblood_viewer.graphics_scene_with_data_controller import QGraphicsImguiSceneWithDataController from PySide2.QtCore import Qt, Slot, Signal, QRectF, QPointF from PySide2.QtWidgets import QWidget, QGraphicsView from PySide2.QtGui import QPainter, QMouseEvent class NodeEditorOverlayBase: - def __init__(self, scene: QGraphicsImguiScene): + def __init__(self, scene: QGraphicsImguiSceneWithDataController): self.__scene = scene self.__enabled = True def name(self) -> str: return re.sub(r'(?<=[a-z0-9])(?=[A-Z])', ' ', self.__class__.__name__) - def scene(self) -> QGraphicsImguiScene: + def scene(self) -> QGraphicsImguiSceneWithDataController: return self.__scene def enabled(self) -> bool: diff --git a/src/lifeblood_viewer/nodeeditor_overlays/task_history_overlay.py b/src/lifeblood_viewer/nodeeditor_overlays/task_history_overlay.py index e9e9ca7a..17ae6ce4 100644 --- a/src/lifeblood_viewer/nodeeditor_overlays/task_history_overlay.py +++ b/src/lifeblood_viewer/nodeeditor_overlays/task_history_overlay.py @@ -3,7 +3,7 @@ from lifeblood.ui_protocol_data import InvocationLogData from lifeblood_viewer.code_editor.editor import StringParameterEditor from lifeblood_viewer.long_op import LongOperation, LongOperationData -from lifeblood_viewer.graphics_scene import QGraphicsImguiScene +from lifeblood_viewer.graphics_scene_with_data_controller import QGraphicsImguiSceneWithDataController from lifeblood_viewer.graphics_items import Task from lifeblood.enums import InvocationState @@ -19,7 +19,7 @@ class TaskHistoryOverlay(NodeEditorOverlayBase): logger = get_logger('viewer.task_history_overlay') - def __init__(self, scene: QGraphicsImguiScene): + def __init__(self, scene: QGraphicsImguiSceneWithDataController): super().__init__(scene) self.__scene = scene self.__pen_line = QPen(QColor(192, 192, 192, 96), 3) diff --git a/src/lifeblood_viewer/scene_data_controller.py b/src/lifeblood_viewer/scene_data_controller.py index e1371014..a094c67f 100644 --- a/src/lifeblood_viewer/scene_data_controller.py +++ b/src/lifeblood_viewer/scene_data_controller.py @@ -1,5 +1,6 @@ from .undo_stack import UndoableOperation, OperationCompletionDetails -from .long_op import LongOperationData +from .long_op import LongOperation, LongOperationData +from .ui_snippets import NodeSnippetData from lifeblood.uidata import Parameter from lifeblood.node_type_metadata import NodeTypeMetadata from lifeblood.enums import TaskState, TaskGroupArchivedState @@ -71,6 +72,9 @@ def request_node_connection_add(self, outnode_id: int, outname: str, innode_id: def request_create_node(self, typename: str, nodename: str, pos: QPointF, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() + def request_create_nodes_from_snippet(self, snippet: NodeSnippetData, pos: QPointF, containing_long_op: Optional[LongOperation] = None): + raise NotImplementedError() + def request_remove_node(self, node_id: int, operation_data: Optional[LongOperationData] = None): raise NotImplementedError() @@ -114,6 +118,7 @@ def request_update_task_attributes(self, task_id: int, attribs_to_update: dict, raise NotImplementedError() def set_skip_dead(self, do_skip: bool) -> None: + # should not be here raise NotImplementedError() def set_skip_archived_groups(self, do_skip: bool) -> None: diff --git a/src/lifeblood_viewer/scene_item_factory_base.py b/src/lifeblood_viewer/scene_item_factory_base.py index f882915b..05f196ff 100644 --- a/src/lifeblood_viewer/scene_item_factory_base.py +++ b/src/lifeblood_viewer/scene_item_factory_base.py @@ -1,5 +1,4 @@ -from .graphics_items import Node, Task, NodeConnection -from .graphics_scene_base import GraphicsSceneBase +from .graphics_items import Node, Task, NodeConnection, GraphicsSceneBase from lifeblood.ui_protocol_data import TaskData diff --git a/src/lifeblood_viewer/scene_ops.py b/src/lifeblood_viewer/scene_ops.py index 486e034c..cccb019c 100644 --- a/src/lifeblood_viewer/scene_ops.py +++ b/src/lifeblood_viewer/scene_ops.py @@ -1,29 +1,42 @@ from lifeblood.logging import get_logger -from .undo_stack import UndoableOperation, StackAwareOperation, SimpleUndoableOperation, OperationError, AsyncOperation, OperationCompletionDetails, OperationCompletionStatus +from .undo_stack import UndoableOperation, SimpleUndoableOperation, OperationError, AsyncOperation, OperationCompletionDetails, OperationCompletionStatus from .long_op import LongOperation, LongOperationData from .ui_snippets import UiNodeSnippetData -from .graphics_items import Node, NodeConnection +from .graphics_items import Node +from .graphics_scene import GraphicsScene +from .scene_data_controller import SceneDataController from lifeblood.snippets import NodeSnippetData from lifeblood.uidata import ParameterLocked, ParameterReadonly from PySide2.QtCore import QPointF -from typing import Callable, TYPE_CHECKING, Optional, List, Mapping, Tuple, Dict, Set, Iterable, Union, Any, Sequence +from typing import Callable, Optional, Tuple, Iterable -if TYPE_CHECKING: - from .graphics_scene import QGraphicsImguiScene logger = get_logger('scene_op') -__all__ = ['CompoundAsyncSceneOperation', 'CreateNodeOp', 'CreateNodesOp', 'RemoveNodesOp', 'RenameNodeOp', - 'MoveNodesOp', 'AddConnectionOp', 'RemoveConnectionOp', 'ParameterChangeOp'] - class AsyncSceneOperation(AsyncOperation): """ base class for async operations on scene """ - def __init__(self, scene: "QGraphicsImguiScene"): - super().__init__(scene._undo_stack(), scene) + def __init__(self, scene: GraphicsScene): + super().__init__(scene.undo_stack(), scene) + self.__scene = scene + + def scene(self) -> GraphicsScene: + return self.__scene + + +class AsyncSceneOperationWithDataController(AsyncSceneOperation): + """ + base class for operations on scene through a data controller + """ + def __init__(self, scene: GraphicsScene, data_controller: SceneDataController): + super().__init__(scene) + self.__data_controller = data_controller + + def data_controller(self) -> SceneDataController: + return self.__data_controller class CompoundAsyncSceneOperation(AsyncSceneOperation): @@ -32,7 +45,7 @@ class CompoundAsyncSceneOperation(AsyncSceneOperation): it makes a number of sequentially performed operations looks like one single operation. provided operations are executed sequentially, and undone in the reversed order """ - def __init__(self, scene: "QGraphicsImguiScene", operations: Iterable[AsyncSceneOperation]): + def __init__(self, scene: GraphicsScene, operations: Iterable[AsyncSceneOperationWithDataController]): super().__init__(scene) self.__ops = tuple(operations) @@ -50,10 +63,9 @@ def __str__(self): return f'Compound Op with {len(self.__ops)} sub-operations' -class CreateNodeOp(AsyncSceneOperation): - def __init__(self, scene: "QGraphicsImguiScene", node_type: str, node_name: str, pos): - super().__init__(scene) - self.__scene: "QGraphicsImguiScene" = scene +class CreateNodeOp(AsyncSceneOperationWithDataController): + def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, node_type: str, node_name: str, pos): + super().__init__(scene, data_controller) self.__node_sid = None self.__node_name = node_name self.__node_type = node_type @@ -61,14 +73,14 @@ def __init__(self, scene: "QGraphicsImguiScene", node_type: str, node_name: str, def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'create node') - self.__scene.request_create_node(self.__node_type, self.__node_name, self.__node_pos, LongOperationData(longop)) + self.data_controller().request_create_node(self.__node_type, self.__node_name, self.__node_pos, LongOperationData(longop)) node_id, node_type, node_name = yield - self.__node_sid = self.__scene._session_node_id_from_id(node_id) + self.__node_sid = self.scene()._session_node_id_from_id(node_id) def _my_undo_longop(self, longop: LongOperation): longop.set_op_status(None, 'undo create node') - node_id = self.__scene._session_node_id_to_id(self.__node_sid) - self.__scene.request_remove_node(node_id, LongOperationData(longop)) + node_id = self.scene()._session_node_id_to_id(self.__node_sid) + self.data_controller().request_remove_node(node_id, LongOperationData(longop)) yield # TODO: shouldn't we check for errors? self.__node_sid = None @@ -76,54 +88,52 @@ def __str__(self): return f'Create Node "{self.__node_name}"' -class CreateNodesOp(AsyncSceneOperation): - def __init__(self, scene: "QGraphicsImguiScene", creation_snippet: NodeSnippetData, pos: QPointF): - super().__init__(scene) - self.__scene: "QGraphicsImguiScene" = scene +class CreateNodesOp(AsyncSceneOperationWithDataController): + def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, creation_snippet: NodeSnippetData, pos: QPointF): + super().__init__(scene, data_controller) self.__node_sids = None self.__creation_snippet = creation_snippet self.__pos = pos def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'create nodes') - self.__scene._request_create_nodes_from_snippet(self.__creation_snippet, self.__pos, longop) + self.data_controller().request_create_nodes_from_snippet(self.__creation_snippet, self.__pos, longop) created_ids = yield - self.__node_sids = set(self.__scene._session_node_id_from_id(nid) for nid in created_ids) + self.__node_sids = set(self.scene()._session_node_id_from_id(nid) for nid in created_ids) def _my_undo_longop(self, longop: LongOperation): print(self.__node_sids) longop.set_op_status(None, 'undo create nodes') - node_ids = [x for x in (self.__scene._session_node_id_to_id(sid) for sid in self.__node_sids) if x is not None] + node_ids = [x for x in (self.scene()._session_node_id_to_id(sid) for sid in self.__node_sids) if x is not None] print(node_ids) if not node_ids: return - self.__scene.request_remove_nodes(node_ids, LongOperationData(longop)) + self.data_controller().request_remove_nodes(node_ids, LongOperationData(longop)) yield def __str__(self): return f'Create Nodes "{self.__node_sids}"' -class RemoveNodesOp(AsyncSceneOperation): - def __init__(self, scene: "QGraphicsImguiScene", nodes: Iterable[Node]): - super().__init__(scene) - self.__scene = scene +class RemoveNodesOp(AsyncSceneOperationWithDataController): + def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, nodes: Iterable[Node]): + super().__init__(scene, data_controller) self.__node_sids = tuple(node.get_session_id() for node in nodes) self.__restoration_snippet = None self.__is_a_noop = False def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'remove nodes') - node_ids = [self.__scene._session_node_id_to_id(sid) for sid in self.__node_sids] - nodes = [self.__scene.get_node(nid) for nid in node_ids] + node_ids = [self.scene()._session_node_id_to_id(sid) for sid in self.__node_sids] + nodes = [self.scene().get_node(nid) for nid in node_ids] if any(n is None for n in nodes): raise OperationError('some nodes disappeared before operation was done') self.__restoration_snippet = UiNodeSnippetData.from_viewer_nodes(nodes, include_dangling_connections=True) - self.__scene.request_remove_nodes(node_ids, LongOperationData(longop)) + self.data_controller().request_remove_nodes(node_ids, LongOperationData(longop)) removed_ids, failed_ids_with_reasons = yield # now filter snippet to remove nodes that scheduler failed to remove #not_removed = set(node_ids) - set(removed_ids) - not_removed_sids = set(self.__scene.get_node(nid).get_session_id() for nid, _ in failed_ids_with_reasons) + not_removed_sids = set(self.scene()._session_node_id_from_id(nid) for nid, _ in failed_ids_with_reasons) reasons = '\n'.join(f'- {nid}: {reason}' for nid, reason in failed_ids_with_reasons) self.__node_sids = tuple(sid for sid in self.__node_sids if sid not in not_removed_sids) op_result = OperationCompletionDetails(OperationCompletionStatus.FullSuccess) @@ -146,9 +156,9 @@ def _my_undo_longop(self, longop: LongOperation): if self.__is_a_noop: return longop.set_op_status(None, 'undo remove nodes') - self.__scene._request_create_nodes_from_snippet(self.__restoration_snippet, QPointF(*self.__restoration_snippet.pos), longop) + self.data_controller().request_create_nodes_from_snippet(self.__restoration_snippet, QPointF(*self.__restoration_snippet.pos), longop) created_ids = yield - sids = set(self.__scene._session_node_id_from_id(nid) for nid in created_ids) + sids = set(self.scene()._session_node_id_from_id(nid) for nid in created_ids) assert set(self.__node_sids) == sids, (sids, set(self.__node_sids)) def __str__(self): @@ -157,31 +167,30 @@ def __str__(self): return f'Remove Nodes {",".join(str(x) for x in self.__node_sids)}' -class RenameNodeOp(AsyncSceneOperation): - def __init__(self, scene: "QGraphicsImguiScene", node: Node, new_name: str): - super().__init__(scene) - self.__scene = scene +class RenameNodeOp(AsyncSceneOperationWithDataController): + def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, node: Node, new_name: str): + super().__init__(scene, data_controller) self.__node_sid = scene._session_node_id_from_id(node.get_id()) self.__old_name = None self.__new_name = new_name def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'rename nodes') - node_id = self.__scene._session_node_id_to_id(self.__node_sid) - node = self.__scene.get_node(node_id) + node_id = self.scene()._session_node_id_to_id(self.__node_sid) + node = self.scene().get_node(node_id) if node is None: raise OperationError(f'node with session id {self.__node_sid} was not found') self.__old_name = node.node_name() - self.__scene.request_set_node_name(node_id, self.__new_name, LongOperationData(longop)) + self.data_controller().request_set_node_name(node_id, self.__new_name, LongOperationData(longop)) yield def _my_undo_longop(self, longop: LongOperation): longop.set_op_status(None, 'undo rename nodes') - node_id = self.__scene._session_node_id_to_id(self.__node_sid) - node = self.__scene.get_node(node_id) + node_id = self.scene()._session_node_id_to_id(self.__node_sid) + node = self.scene().get_node(node_id) if node is None: raise OperationError(f'node with session id {self.__node_sid} was not found') - self.__scene.request_set_node_name(node_id, self.__old_name, LongOperationData(longop)) + self.data_controller().request_set_node_name(node_id, self.__old_name, LongOperationData(longop)) yield def __str__(self): @@ -189,12 +198,12 @@ def __str__(self): class MoveNodesOp(SimpleUndoableOperation): - def __init__(self, scene: "QGraphicsImguiScene", info: Iterable[Tuple[Node, QPointF, Optional[QPointF]]]): - super().__init__(scene._undo_stack(), self._doop, self._undoop) + def __init__(self, scene: GraphicsScene, info: Iterable[Tuple[Node, QPointF, Optional[QPointF]]]): + super().__init__(scene.undo_stack(), self._doop, self._undoop) self.__scene = scene self.__node_info = tuple((scene._session_node_id_from_id(node.get_id()), new_pos, old_pos) for node, new_pos, old_pos in info) - def _doop(self, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None): + def _doop(self, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): for node_sid, new_pos, old_pos in self.__node_info: node_id = self.__scene._session_node_id_to_id(node_sid) node = self.__scene.get_node(node_id) @@ -204,7 +213,7 @@ def _doop(self, callback: Optional[Callable[["UndoableOperation", OperationCompl if callback: callback(self, OperationCompletionDetails(OperationCompletionStatus.FullSuccess)) - def _undoop(self, callback: Optional[Callable[["UndoableOperation"], None]] = None): + def _undoop(self, callback: Optional[Callable[[UndoableOperation], None]] = None): for node_sid, new_pos, old_pos in self.__node_info: node_id = self.__scene._session_node_id_to_id(node_sid) node = self.__scene.get_node(node_id) @@ -218,10 +227,9 @@ def __str__(self): return f'Move Node(s) {",".join(str(x) for x,_,_ in self.__node_info)}' -class AddConnectionOp(AsyncSceneOperation): - def __init__(self, scene: "QGraphicsImguiScene", out_node: Node, out_name: str, in_node: Node, in_name: str): - super().__init__(scene) - self.__scene = scene +class AddConnectionOp(AsyncSceneOperationWithDataController): + def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, out_node: Node, out_name: str, in_node: Node, in_name: str): + super().__init__(scene, data_controller) self.__out_sid = out_node.get_session_id() self.__out_name = out_name self.__in_sid = in_node.get_session_id() @@ -229,40 +237,39 @@ def __init__(self, scene: "QGraphicsImguiScene", out_node: Node, out_name: str, def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'add connection') - out_id = self.__scene._session_node_id_to_id(self.__out_sid) - in_id = self.__scene._session_node_id_to_id(self.__in_sid) + out_id = self.scene()._session_node_id_to_id(self.__out_sid) + in_id = self.scene()._session_node_id_to_id(self.__in_sid) if out_id is None or in_id is None \ - or self.__scene.get_node(out_id) is None \ - or self.__scene.get_node(in_id) is None: + or self.scene().get_node(out_id) is None \ + or self.scene().get_node(in_id) is None: logger.warning(f'could not perform op: nodes not found {out_id}, {in_id}') return - self.__scene.request_node_connection_add(out_id, self.__out_name, in_id, self.__in_name, LongOperationData(longop)) + self.data_controller().request_node_connection_add(out_id, self.__out_name, in_id, self.__in_name, LongOperationData(longop)) yield def _my_undo_longop(self, longop: LongOperation): longop.set_op_status(None, 'undo add connection') - out_id = self.__scene._session_node_id_to_id(self.__out_sid) - in_id = self.__scene._session_node_id_to_id(self.__in_sid) + out_id = self.scene()._session_node_id_to_id(self.__out_sid) + in_id = self.scene()._session_node_id_to_id(self.__in_sid) if out_id is None or in_id is None \ - or self.__scene.get_node(out_id) is None \ - or self.__scene.get_node(in_id) is None: + or self.scene().get_node(out_id) is None \ + or self.scene().get_node(in_id) is None: logger.warning(f'could not perform undo: added connection not found: {out_id} {in_id}') return - con = self.__scene.get_node_connection_from_ends(out_id, self.__out_name, in_id, self.__in_name) + con = self.scene().get_node_connection_from_ends(out_id, self.__out_name, in_id, self.__in_name) if con is None: logger.warning('could not perform undo: added connection not found') return - self.__scene.request_node_connection_remove(con.get_id(), LongOperationData(longop)) + self.data_controller().request_node_connection_remove(con.get_id(), LongOperationData(longop)) yield # TODO: check for errors def __str__(self): return f'Wire Add {self.__out_sid}:{self.__out_name}->{self.__in_sid}:{self.__in_name}' -class RemoveConnectionOp(AsyncSceneOperation): - def __init__(self, scene: "QGraphicsImguiScene", out_node: Node, out_name: str, in_node: Node, in_name: str): - super().__init__(scene) - self.__scene = scene +class RemoveConnectionOp(AsyncSceneOperationWithDataController): + def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, out_node: Node, out_name: str, in_node: Node, in_name: str): + super().__init__(scene, data_controller) self.__out_sid = out_node.get_session_id() self.__out_name = out_name self.__in_sid = in_node.get_session_id() @@ -270,18 +277,18 @@ def __init__(self, scene: "QGraphicsImguiScene", out_node: Node, out_name: str, def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'remove connection') - out_id = self.__scene._session_node_id_to_id(self.__out_sid) - in_id = self.__scene._session_node_id_to_id(self.__in_sid) + out_id = self.scene()._session_node_id_to_id(self.__out_sid) + in_id = self.scene()._session_node_id_to_id(self.__in_sid) if out_id is None or in_id is None \ - or self.__scene.get_node(out_id) is None \ - or self.__scene.get_node(in_id) is None: + or self.scene().get_node(out_id) is None \ + or self.scene().get_node(in_id) is None: logger.warning(f'could not perform op: added connection not found: {out_id} {in_id}') return - con = self.__scene.get_node_connection_from_ends(out_id, self.__out_name, in_id, self.__in_name) + con = self.scene().get_node_connection_from_ends(out_id, self.__out_name, in_id, self.__in_name) if con is None: logger.warning(f'could not perform op: added connection not found for {out_id}, {self.__out_name}, {in_id}, {self.__in_name}') return - self.__scene.request_node_connection_remove(con.get_id(), LongOperationData(longop)) + self.data_controller().request_node_connection_remove(con.get_id(), LongOperationData(longop)) _, failed_ids_with_reasons = yield reasons = '\n'.join(f'- {reason}' for _, reason in failed_ids_with_reasons) op_result = OperationCompletionDetails(OperationCompletionStatus.FullSuccess) @@ -292,22 +299,22 @@ def _my_do_longop(self, longop: LongOperation): def _my_undo_longop(self, longop: LongOperation): longop.set_op_status(None, 'undo remove connection') - out_id = self.__scene._session_node_id_to_id(self.__out_sid) - in_id = self.__scene._session_node_id_to_id(self.__in_sid) + out_id = self.scene()._session_node_id_to_id(self.__out_sid) + in_id = self.scene()._session_node_id_to_id(self.__in_sid) if out_id is None or in_id is None \ - or self.__scene.get_node(out_id) is None \ - or self.__scene.get_node(in_id) is None: + or self.scene().get_node(out_id) is None \ + or self.scene().get_node(in_id) is None: logger.warning('could not perform undo: added connection not found') return - self.__scene.request_node_connection_add(out_id, self.__out_name, in_id, self.__in_name, LongOperationData(longop)) + self.data_controller().request_node_connection_add(out_id, self.__out_name, in_id, self.__in_name, LongOperationData(longop)) yield def __str__(self): return f'Wire Remove {self.__out_sid}:{self.__out_name}->{self.__in_sid}:{self.__in_name}' -class ParameterChangeOp(AsyncSceneOperation): - def __init__(self, scene: "QGraphicsImguiScene", node: Node, parameter_name: str, new_value=..., new_expression=...): +class ParameterChangeOp(AsyncSceneOperationWithDataController): + def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, node: Node, parameter_name: str, new_value=..., new_expression=...): """ :param scene: @@ -316,8 +323,7 @@ def __init__(self, scene: "QGraphicsImguiScene", node: Node, parameter_name: str :param new_value: ...(Ellipsis) means no change :param new_expression: ...(Ellipsis) means no change """ - super().__init__(scene) - self.__scene = scene + super().__init__(scene, data_controller) self.__param_name = parameter_name node_sid = node.get_session_id() param = scene.get_node(scene._session_node_id_to_id(node_sid)).get_nodeui().parameter(parameter_name) @@ -329,8 +335,8 @@ def __init__(self, scene: "QGraphicsImguiScene", node: Node, parameter_name: str def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'change parameter value') - node_id = self.__scene._session_node_id_to_id(self.__node_sid) - param = self.__scene.get_node(node_id).get_nodeui().parameter(self.__param_name) + node_id = self.scene()._session_node_id_to_id(self.__node_sid) + param = self.scene().get_node(node_id).get_nodeui().parameter(self.__param_name) try: if self.__new_value is not ...: param.set_value(self.__new_value) @@ -343,18 +349,18 @@ def _my_do_longop(self, longop: LongOperation): self._set_result(OperationCompletionDetails(OperationCompletionStatus.NotPerformed, 'parameter is read only')) return # TODO: currently possible errors on scheduler side are ignored, not good - self.__scene.request_node_parameters_change(node_id, [param], LongOperationData(longop)) + self.data_controller().request_node_parameters_change(node_id, [param], LongOperationData(longop)) yield def _my_undo_longop(self, longop: LongOperation): longop.set_op_status(None, 'undo change parameter value') - node_id = self.__scene._session_node_id_to_id(self.__node_sid) - param = self.__scene.get_node(node_id).get_nodeui().parameter(self.__param_name) + node_id = self.scene()._session_node_id_to_id(self.__node_sid) + param = self.scene().get_node(node_id).get_nodeui().parameter(self.__param_name) if self.__old_value is not ...: param.set_value(self.__old_value) if self.__old_expression is not ...: param.set_expression(self.__old_expression) - self.__scene.request_node_parameters_change(node_id, [param], LongOperationData(longop)) + self.data_controller().request_node_parameters_change(node_id, [param], LongOperationData(longop)) yield def __str__(self): diff --git a/src/lifeblood_viewer/ui_scene_elements.py b/src/lifeblood_viewer/ui_scene_elements.py index 52373bff..13fc7a34 100644 --- a/src/lifeblood_viewer/ui_scene_elements.py +++ b/src/lifeblood_viewer/ui_scene_elements.py @@ -1,7 +1,7 @@ import imgui from .ui_elements_base import ImguiWindow, ImguiPopup from .nodeeditor import NodeEditor -from .graphics_scene import QGraphicsImguiScene +from .graphics_scene_with_data_controller import QGraphicsImguiSceneWithDataController from typing import Optional, Tuple @@ -14,7 +14,7 @@ def __init__(self, editor_widget: NodeEditor, title: str = '', closable: bool = def editor_widget(self) -> NodeEditor: return self.__editor - def scene(self) -> QGraphicsImguiScene: + def scene(self) -> QGraphicsImguiSceneWithDataController: return self.__editor.scene() def popup(self): @@ -34,7 +34,7 @@ def __init__(self, editor_widget: NodeEditor, title: str = ''): def editor_widget(self) -> NodeEditor: return self.__editor - def scene(self) -> QGraphicsImguiScene: + def scene(self) -> QGraphicsImguiSceneWithDataController: return self.__editor.scene() def popup(self): diff --git a/src/lifeblood_viewer/undo_stack.py b/src/lifeblood_viewer/undo_stack.py index a7d04396..1facfde0 100644 --- a/src/lifeblood_viewer/undo_stack.py +++ b/src/lifeblood_viewer/undo_stack.py @@ -76,7 +76,7 @@ class StackAwareOperation(UndoableOperation): def __init__(self, undo_stack: "UndoStack"): self.__stack = undo_stack - def _undo_stack(self): + def undo_stack(self): return self.__stack def do(self, callback: Optional[Callable[["UndoableOperation", OperationCompletionDetails], None]] = None) -> bool: @@ -176,7 +176,7 @@ def doop(longop: LongOperation): finally: op_result = self._my_do_result() assert op_result is not None - self._undo_stack()._operation_finalized(op=self, add_to_stack=op_result.status != OperationCompletionStatus.NotPerformed, success=success) + self.undo_stack()._operation_finalized(op=self, add_to_stack=op_result.status != OperationCompletionStatus.NotPerformed, success=success) if success and callback: callback(self, op_result) @@ -196,7 +196,7 @@ def undoop(longop: LongOperation): logger.exception(f'exception happened during do operation "{self}"') success = False finally: - self._undo_stack()._operation_finalized(self, False, success) + self.undo_stack()._operation_finalized(self, False, success) if success and callback: callback(self) From 530c10ae24b69b9f568be606cc313a88b8127b22 Mon Sep 17 00:00:00 2001 From: pedohorse <13556996+pedohorse@users.noreply.github.com> Date: Sat, 14 Sep 2024 15:14:32 +0200 Subject: [PATCH 6/6] cleanup --- src/lifeblood_viewer/connection_worker.py | 4 +- .../graphics_items/graphics_items.py | 2 +- .../graphics_items/graphics_scene_base.py | 11 +- src/lifeblood_viewer/graphics_scene.py | 2 +- .../graphics_scene_with_data_controller.py | 188 +----------------- .../task_history_overlay.py | 2 +- src/lifeblood_viewer/scene_data_controller.py | 1 - src/lifeblood_viewer/scene_ops.py | 48 ++--- 8 files changed, 44 insertions(+), 214 deletions(-) diff --git a/src/lifeblood_viewer/connection_worker.py b/src/lifeblood_viewer/connection_worker.py index 15c3347f..c6a81141 100644 --- a/src/lifeblood_viewer/connection_worker.py +++ b/src/lifeblood_viewer/connection_worker.py @@ -39,7 +39,7 @@ class SchedulerConnectionWorker(PySide2.QtCore.QObject): db_uid_update = Signal(object) graph_full_update = Signal(object) tasks_full_update = Signal(object) - tasks_events_arrived = Signal(object, bool) + tasks_events_arrived = Signal(object) groups_full_update = Signal(object) workers_full_update = Signal(object) scheduler_connection_lost = Signal() @@ -400,7 +400,7 @@ def _check_tasks(self): if len(task_events) > 0: first_time_getting_events = self.__last_known_event_id < 0 self.__last_known_event_id = task_events[-1].event_id - self.tasks_events_arrived.emit(task_events, first_time_getting_events) + self.tasks_events_arrived.emit(task_events) else: tasks_state = self.__client.get_ui_tasks_state(self.__task_group_filter or [], not self.__skip_dead) self.tasks_full_update.emit(tasks_state) diff --git a/src/lifeblood_viewer/graphics_items/graphics_items.py b/src/lifeblood_viewer/graphics_items/graphics_items.py index d6734dbd..b7179aa9 100644 --- a/src/lifeblood_viewer/graphics_items/graphics_items.py +++ b/src/lifeblood_viewer/graphics_items/graphics_items.py @@ -62,7 +62,7 @@ def get_session_id(self): session id is local id that should be preserved within a session even after undo/redo operations, unlike simple id, that will change on undo/redo """ - return self.graphics_scene()._session_node_id_from_id(self.get_id()) + return self.graphics_scene().session_node_id_from_id(self.get_id()) def node_type(self) -> str: return self.__node_type diff --git a/src/lifeblood_viewer/graphics_items/graphics_scene_base.py b/src/lifeblood_viewer/graphics_items/graphics_scene_base.py index 2bd4da51..386f07be 100644 --- a/src/lifeblood_viewer/graphics_items/graphics_scene_base.py +++ b/src/lifeblood_viewer/graphics_items/graphics_scene_base.py @@ -23,8 +23,7 @@ def get_inspected_item(self) -> Optional[NetworkItem]: return None return sel[0] - # TODO: rename this shit - it should not really be aware of "node" concept here - def _session_node_id_to_id(self, session_id: int) -> Optional[int]: + def session_node_id_to_id(self, session_id: int) -> Optional[int]: """ the whole idea of session id is to have it consistent through undo-redos """ @@ -35,7 +34,7 @@ def _session_node_id_to_id(self, session_id: int) -> Optional[int]: node_id = None return node_id - def _session_node_update_id(self, session_id: int, new_node_id: int): + def __session_node_update_id(self, session_id: int, new_node_id: int): prev_node_id = self.__session_node_id_mapping.get(session_id) self.__session_node_id_mapping[session_id] = new_node_id if prev_node_id is not None: @@ -52,10 +51,10 @@ def _session_node_update_session_id(self, new_session_id: int, node_id: int): self.__session_node_id_mapping.pop(old_session_id) self.__session_node_id_mapping[new_session_id] = node_id - def _session_node_id_from_id(self, node_id: int): + def session_node_id_from_id(self, node_id: int): if node_id not in self.__session_node_id_mapping_rev: - while self._session_node_id_to_id(self.__next_session_node_id) is not None: # they may be taken by pasted nodes + while self.session_node_id_to_id(self.__next_session_node_id) is not None: # they may be taken by pasted nodes self.__next_session_node_id -= 1 - self._session_node_update_id(self.__next_session_node_id, node_id) + self.__session_node_update_id(self.__next_session_node_id, node_id) self.__next_session_node_id -= 1 return self.__session_node_id_mapping_rev[node_id] diff --git a/src/lifeblood_viewer/graphics_scene.py b/src/lifeblood_viewer/graphics_scene.py index 97aa7eac..cc4ee93c 100644 --- a/src/lifeblood_viewer/graphics_scene.py +++ b/src/lifeblood_viewer/graphics_scene.py @@ -2,7 +2,7 @@ from lifeblood.config import get_config from .graphics_items.graphics_scene_container import GraphicsSceneWithNodesAndTasks from .long_op import LongOperation, LongOperationData, LongOperationProcessor -from .undo_stack import UndoStack, UndoableOperation, OperationCompletionDetails +from .undo_stack import UndoStack, UndoableOperation from PySide2.QtCore import Slot from typing import Callable, Dict, Generator, List, Optional, Tuple diff --git a/src/lifeblood_viewer/graphics_scene_with_data_controller.py b/src/lifeblood_viewer/graphics_scene_with_data_controller.py index 031116c9..83f90bc5 100644 --- a/src/lifeblood_viewer/graphics_scene_with_data_controller.py +++ b/src/lifeblood_viewer/graphics_scene_with_data_controller.py @@ -4,7 +4,7 @@ import grandalf.layouts from types import MappingProxyType -from .graphics_items import Task, Node, NodeConnection, GraphicsSceneWithNodesAndTasks +from .graphics_items import Task, Node, NodeConnection from .graphics_items.qextended_graphics_item import QGraphicsItemExtended from .db_misc import sql_init_script_nodes from .long_op import LongOperation, LongOperationData @@ -22,9 +22,9 @@ AddConnectionOp, RemoveConnectionOp, ParameterChangeOp) -from lifeblood.misc import timeit, performance_measurer +from lifeblood.misc import timeit from lifeblood.uidata import NodeUi, Parameter -from lifeblood.ui_protocol_data import UiData, TaskBatchData, NodeGraphStructureData, TaskDelta, DataNotSet, IncompleteInvocationLogData, InvocationLogData +from lifeblood.ui_protocol_data import TaskBatchData, NodeGraphStructureData, TaskDelta, DataNotSet, IncompleteInvocationLogData, InvocationLogData from lifeblood.enums import TaskState, TaskGroupArchivedState from lifeblood import logging from lifeblood.node_type_metadata import NodeTypeMetadata @@ -33,13 +33,12 @@ from lifeblood.snippets import NodeSnippetData, NodeSnippetDataPlaceholder from lifeblood.environment_resolver import EnvironmentResolverArguments from lifeblood.ui_events import TaskEvent, TasksRemoved, TasksUpdated, TasksChanged, TaskFullState -from lifeblood.config import get_config from PySide2.QtWidgets import * from PySide2.QtCore import Slot, Signal, QThread, QRectF, QPointF from PySide2.QtGui import QKeyEvent -from typing import Callable, Generator, Optional, List, Mapping, Tuple, Dict, Set, Iterable, Union, Any, Sequence +from typing import Callable, Optional, List, Tuple, Dict, Set, Iterable, Union, Any, Sequence logger = logging.get_logger('viewer') @@ -501,7 +500,7 @@ def change_node_parameter(self, node_id: int, item: Parameter, value: Any = ..., :return: """ logger.debug(f'node:{node_id}, changing "{item.name()}" to {repr(value)}/({expression})') - node_sid = self._session_node_id_from_id(node_id) + node_sid = self.session_node_id_from_id(node_id) op = ParameterChangeOp(self, self, self.get_node(node_id), item.name(), value, expression) op.do(callback) @@ -586,9 +585,6 @@ def graph_full_update(self, graph_data: NodeGraphStructureData): to_del.append(item) continue existing_conn_ids[item.get_id()] = item - print('---') - print(existing_node_ids) - print('---') # delete things for item in to_del: @@ -653,13 +649,9 @@ def graph_full_update(self, graph_data: NodeGraphStructureData): if nodes_to_layout: self.layout_nodes(nodes_to_layout) - print('+++') - print(self.items()) - print(self.nodes()) - @timeit(0.05) @Slot(object, bool) - def tasks_process_events(self, events: List[TaskEvent], first_time_getting_events: bool): + def tasks_process_events(self, events: List[TaskEvent]): """ :param events: @@ -766,166 +758,6 @@ def tasks_update(self, tasks_data: TaskBatchData): self.__tasks_to_try_reparent_during_node_update[id] = new_task_data.node_id task.set_task_data(new_task_data) - # @timeit(0.05) - # @Slot(object) - # def full_update(self, uidata: UiData): - # raise DeprecationWarning('no use') - # # logger.debug('full_update') - # - # if self.__db_uid is not None and self.__db_uid != uidata.db_uid: - # logger.info('scheduler\'s database changed. resetting the view...') - # self.save_node_layout() - # self.clear() - # self.__db_uid = None - # self.__nodes_table_name = None - # # this means we probably reconnected to another scheduler, so existing nodes need to be dropped - # - # if self.__db_uid is None: - # self.__db_uid = uidata.db_uid - # self.__nodes_table_name = f'nodes_{self.__db_uid}' - # with sqlite3.connect(self.__db_path) as con: - # con.executescript(sql_init_script_nodes.format(db_uid=self.__db_uid)) - # - # to_del = [] - # to_del_tasks = {} - # existing_node_ids: Dict[int, Node] = {} - # existing_conn_ids: Dict[int, NodeConnection] = {} - # existing_task_ids: Dict[int, Task] = {} - # _perf_total = 0.0 - # graph_data = uidata.graph_data - # with performance_measurer() as pm: - # for item in self.items(): - # if isinstance(item, Node): # TODO: unify this repeating code and move the setting attribs to after all elements are created - # if item.get_id() not in graph_data.nodes or item.node_type() != graph_data.nodes[item.get_id()].type: - # to_del.append(item) - # continue - # existing_node_ids[item.get_id()] = item - # # TODO: update all kind of attribs here, for now we just don't have any - # elif isinstance(item, NodeConnection): - # if item.get_id() not in graph_data.connections: - # to_del.append(item) - # continue - # existing_conn_ids[item.get_id()] = item - # # TODO: update all kind of attribs here, for now we just don't have any - # elif isinstance(item, Task): - # if item.get_id() not in uidata.tasks.tasks: - # to_del.append(item) - # if item.node() is not None: - # if not item.node() in to_del_tasks: - # to_del_tasks[item.node()] = [] - # to_del_tasks[item.node()].append(item) - # continue - # existing_task_ids[item.get_id()] = item - # _perf_item_classify = pm.elapsed() - # _perf_total += pm.elapsed() - # - # # before we delete everything - we'll remove tasks from nodes to avoid deleting tasks one by one triggering tonns of animation - # with performance_measurer() as pm: - # for node, tasks in to_del_tasks.items(): - # node.remove_tasks(tasks) - # _perf_remove_tasks = pm.elapsed() - # _perf_total += pm.elapsed() - # with performance_measurer() as pm: - # for item in to_del: - # self.removeItem(item) - # _perf_remove_items = pm.elapsed() - # _perf_total += pm.elapsed() - # # removing items might cascade things, like removing node will remove connections to that node - # # so now we need to recheck existing items validity - # # though not consistent scene states should not come in uidata at all - # with performance_measurer() as pm: - # for existings in (existing_node_ids, existing_task_ids, existing_conn_ids): - # for item_id, item in tuple(existings.items()): - # if item.scene() != self: - # del existings[item_id] - # _perf_revalidate = pm.elapsed() - # _perf_total += pm.elapsed() - # - # nodes_to_layout = [] - # with performance_measurer() as pm: - # for id, new_node_data in graph_data.nodes.items(): - # if id in existing_node_ids: - # existing_node_ids[id].set_name(new_node_data.name) - # continue - # new_node = self.__scene_item_factory.make_node(self, id, new_node_data.type, new_node_data.name or f'node #{id}') - # try: - # new_node.setPos(*self.node_position(id)) - # except ValueError: - # nodes_to_layout.append(new_node) - # existing_node_ids[id] = new_node - # self.addItem(new_node) - # _perf_create_nodes = pm.elapsed() - # _perf_total += pm.elapsed() - # - # with performance_measurer() as pm: - # for id, new_conn_data in graph_data.connections.items(): - # if id in existing_conn_ids: - # # ensure connections - # innode, inname = existing_conn_ids[id].input() - # outnode, outname = existing_conn_ids[id].output() - # if innode.get_id() != new_conn_data.in_id or inname != new_conn_data.in_name: - # existing_conn_ids[id].set_input(existing_node_ids[new_conn_data.in_id], new_conn_data.in_name) - # existing_conn_ids[id].update() - # if outnode.get_id() != new_conn_data.out_id or outname != new_conn_data.out_name: - # existing_conn_ids[id].set_output(existing_node_ids[new_conn_data.out_id], new_conn_data.out_name) - # existing_conn_ids[id].update() - # continue - # new_conn = self.__scene_item_factory.make_node_connection( - # self, - # id, - # existing_node_ids[new_conn_data.out_id], - # existing_node_ids[new_conn_data.in_id], - # new_conn_data.out_name, new_conn_data.in_name - # ) - # existing_conn_ids[id] = new_conn - # self.addItem(new_conn) - # _perf_create_connections = pm.elapsed() - # _perf_total += pm.elapsed() - # - # with performance_measurer() as pm: - # for id, new_task_data in uidata.tasks.tasks.items(): - # if id not in existing_task_ids: - # new_task = self.__scene_item_factory.make_task(self, new_task_data) - # existing_task_ids[id] = new_task - # if new_task_data.split_origin_task_id is not None and new_task_data.split_origin_task_id in existing_task_ids: # TODO: bug: this and below will only work if parent/original tasks were created during previous updates - # origin_task = existing_task_ids[new_task_data.split_origin_task_id] - # new_task.setPos(origin_task.scenePos()) - # elif new_task_data.parent_id is not None and new_task_data.parent_id in existing_task_ids: - # origin_task = existing_task_ids[new_task_data.parent_id] - # new_task.setPos(origin_task.scenePos()) - # self.addItem(new_task) - # task = existing_task_ids[id] - # existing_node_ids[new_task_data.node_id].add_task(task) - # task.set_task_data(new_task_data) - # _perf_create_tasks = pm.elapsed() - # _perf_total += pm.elapsed() - # - # # now layout nodes that need it - # with performance_measurer() as pm: - # if nodes_to_layout: - # self.layout_nodes(nodes_to_layout) - # _perf_layout = pm.elapsed() - # _perf_total += pm.elapsed() - # - # with performance_measurer() as pm: - # if self.__all_task_groups != uidata.task_groups: - # self.__all_task_groups = uidata.task_groups - # self.task_groups_updated.emit(uidata.task_groups) - # _perf_task_groups_update = pm.elapsed() - # _perf_total += pm.elapsed() - # - # if _perf_total > 0.04: # arbitrary threshold ~ 1/25 of a sec - # logger.debug(f'update performed:\n' - # f'{_perf_item_classify:.04f}:\tclassify\n' - # f'{_perf_remove_tasks:.04f}:\tremove tasks\n' - # f'{_perf_remove_items:.04f}:\tremove items\n' - # f'{_perf_revalidate:.04f}:\trevalidate\n' - # f'{_perf_create_nodes:.04f}:\tcreate nodes\n' - # f'{_perf_create_connections:.04f}:\tcreate connections\n' - # f'{_perf_create_tasks:.04f}:\tcreate tasks\n' - # f'{_perf_layout:.04f}:\tlayout\n' - # f'{_perf_task_groups_update:.04f}:\ttask group update') - @Slot(object, object, bool, object) def log_fetched(self, task_id: int, log: Dict[int, Dict[int, Union[IncompleteInvocationLogData, InvocationLogData]]], full_update, data: Optional["LongOperationData"] = None): """ @@ -1139,7 +971,7 @@ def pasteop(longop): created_nodes.append(node_id) # assign session ids to new nodes, prefer tmp ids from the snippet - if self._session_node_id_to_id(nodedata.tmpid) is None: # session id is free + if self.session_node_id_to_id(nodedata.tmpid) is None: # session id is free self._session_node_update_session_id(nodedata.tmpid, node_id) proxy_params = [] @@ -1159,8 +991,8 @@ def pasteop(longop): if total_elements > 1: longop.set_op_status(current_element / (total_elements - 1), opname) - con_out = tmp_to_new.get(conndata.tmpout, self._session_node_id_to_id(conndata.tmpout)) - con_in = tmp_to_new.get(conndata.tmpin, self._session_node_id_to_id(conndata.tmpin)) + con_out = tmp_to_new.get(conndata.tmpout, self.session_node_id_to_id(conndata.tmpout)) + con_in = tmp_to_new.get(conndata.tmpin, self.session_node_id_to_id(conndata.tmpin)) if con_out is None or con_in is None: logger.warning('failed to create connection during snippet creation!') continue @@ -1190,7 +1022,7 @@ def savesettingsop(longop): # def get_node_by_session_id(self, node_session_id) -> Optional[Node]: - node_id = self._session_node_id_to_id(node_session_id) + node_id = self.session_node_id_to_id(node_session_id) if node_id is None: return None return self.get_node(node_id, None) diff --git a/src/lifeblood_viewer/nodeeditor_overlays/task_history_overlay.py b/src/lifeblood_viewer/nodeeditor_overlays/task_history_overlay.py index 17ae6ce4..2cfdfe0f 100644 --- a/src/lifeblood_viewer/nodeeditor_overlays/task_history_overlay.py +++ b/src/lifeblood_viewer/nodeeditor_overlays/task_history_overlay.py @@ -66,10 +66,10 @@ def draw_scene_foreground(self, painter: QPainter, rect: QRectF): self.__buttons = {} for i, (inv_id, node_id, log_meta) in enumerate(reversed(task.invocation_logs())): + bbox = self.__scene.get_node(node_id).boundingRect() if node_id not in already_visited_nodes: already_visited_nodes.add(node_id) pos = self.__scene.get_node(node_id).scenePos() - bbox = self.__scene.get_node(node_id).boundingRect() target_pos = pos + bbox.bottomLeft() + self.__node_offset if not rect.contains(target_pos) and not rect.contains(path.currentPosition()): path.moveTo(bbox.topLeft() + pos + self.__node_offset) diff --git a/src/lifeblood_viewer/scene_data_controller.py b/src/lifeblood_viewer/scene_data_controller.py index a094c67f..93a3c44e 100644 --- a/src/lifeblood_viewer/scene_data_controller.py +++ b/src/lifeblood_viewer/scene_data_controller.py @@ -118,7 +118,6 @@ def request_update_task_attributes(self, task_id: int, attribs_to_update: dict, raise NotImplementedError() def set_skip_dead(self, do_skip: bool) -> None: - # should not be here raise NotImplementedError() def set_skip_archived_groups(self, do_skip: bool) -> None: diff --git a/src/lifeblood_viewer/scene_ops.py b/src/lifeblood_viewer/scene_ops.py index cccb019c..355a864d 100644 --- a/src/lifeblood_viewer/scene_ops.py +++ b/src/lifeblood_viewer/scene_ops.py @@ -75,11 +75,11 @@ def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'create node') self.data_controller().request_create_node(self.__node_type, self.__node_name, self.__node_pos, LongOperationData(longop)) node_id, node_type, node_name = yield - self.__node_sid = self.scene()._session_node_id_from_id(node_id) + self.__node_sid = self.scene().session_node_id_from_id(node_id) def _my_undo_longop(self, longop: LongOperation): longop.set_op_status(None, 'undo create node') - node_id = self.scene()._session_node_id_to_id(self.__node_sid) + node_id = self.scene().session_node_id_to_id(self.__node_sid) self.data_controller().request_remove_node(node_id, LongOperationData(longop)) yield # TODO: shouldn't we check for errors? self.__node_sid = None @@ -99,12 +99,12 @@ def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'create nodes') self.data_controller().request_create_nodes_from_snippet(self.__creation_snippet, self.__pos, longop) created_ids = yield - self.__node_sids = set(self.scene()._session_node_id_from_id(nid) for nid in created_ids) + self.__node_sids = set(self.scene().session_node_id_from_id(nid) for nid in created_ids) def _my_undo_longop(self, longop: LongOperation): print(self.__node_sids) longop.set_op_status(None, 'undo create nodes') - node_ids = [x for x in (self.scene()._session_node_id_to_id(sid) for sid in self.__node_sids) if x is not None] + node_ids = [x for x in (self.scene().session_node_id_to_id(sid) for sid in self.__node_sids) if x is not None] print(node_ids) if not node_ids: return @@ -124,7 +124,7 @@ def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, n def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'remove nodes') - node_ids = [self.scene()._session_node_id_to_id(sid) for sid in self.__node_sids] + node_ids = [self.scene().session_node_id_to_id(sid) for sid in self.__node_sids] nodes = [self.scene().get_node(nid) for nid in node_ids] if any(n is None for n in nodes): raise OperationError('some nodes disappeared before operation was done') @@ -133,7 +133,7 @@ def _my_do_longop(self, longop: LongOperation): removed_ids, failed_ids_with_reasons = yield # now filter snippet to remove nodes that scheduler failed to remove #not_removed = set(node_ids) - set(removed_ids) - not_removed_sids = set(self.scene()._session_node_id_from_id(nid) for nid, _ in failed_ids_with_reasons) + not_removed_sids = set(self.scene().session_node_id_from_id(nid) for nid, _ in failed_ids_with_reasons) reasons = '\n'.join(f'- {nid}: {reason}' for nid, reason in failed_ids_with_reasons) self.__node_sids = tuple(sid for sid in self.__node_sids if sid not in not_removed_sids) op_result = OperationCompletionDetails(OperationCompletionStatus.FullSuccess) @@ -158,7 +158,7 @@ def _my_undo_longop(self, longop: LongOperation): longop.set_op_status(None, 'undo remove nodes') self.data_controller().request_create_nodes_from_snippet(self.__restoration_snippet, QPointF(*self.__restoration_snippet.pos), longop) created_ids = yield - sids = set(self.scene()._session_node_id_from_id(nid) for nid in created_ids) + sids = set(self.scene().session_node_id_from_id(nid) for nid in created_ids) assert set(self.__node_sids) == sids, (sids, set(self.__node_sids)) def __str__(self): @@ -170,13 +170,13 @@ def __str__(self): class RenameNodeOp(AsyncSceneOperationWithDataController): def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, node: Node, new_name: str): super().__init__(scene, data_controller) - self.__node_sid = scene._session_node_id_from_id(node.get_id()) + self.__node_sid = scene.session_node_id_from_id(node.get_id()) self.__old_name = None self.__new_name = new_name def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'rename nodes') - node_id = self.scene()._session_node_id_to_id(self.__node_sid) + node_id = self.scene().session_node_id_to_id(self.__node_sid) node = self.scene().get_node(node_id) if node is None: raise OperationError(f'node with session id {self.__node_sid} was not found') @@ -186,7 +186,7 @@ def _my_do_longop(self, longop: LongOperation): def _my_undo_longop(self, longop: LongOperation): longop.set_op_status(None, 'undo rename nodes') - node_id = self.scene()._session_node_id_to_id(self.__node_sid) + node_id = self.scene().session_node_id_to_id(self.__node_sid) node = self.scene().get_node(node_id) if node is None: raise OperationError(f'node with session id {self.__node_sid} was not found') @@ -201,11 +201,11 @@ class MoveNodesOp(SimpleUndoableOperation): def __init__(self, scene: GraphicsScene, info: Iterable[Tuple[Node, QPointF, Optional[QPointF]]]): super().__init__(scene.undo_stack(), self._doop, self._undoop) self.__scene = scene - self.__node_info = tuple((scene._session_node_id_from_id(node.get_id()), new_pos, old_pos) for node, new_pos, old_pos in info) + self.__node_info = tuple((scene.session_node_id_from_id(node.get_id()), new_pos, old_pos) for node, new_pos, old_pos in info) def _doop(self, callback: Optional[Callable[[UndoableOperation, OperationCompletionDetails], None]] = None): for node_sid, new_pos, old_pos in self.__node_info: - node_id = self.__scene._session_node_id_to_id(node_sid) + node_id = self.__scene.session_node_id_to_id(node_sid) node = self.__scene.get_node(node_id) if node is None: raise OperationError(f'node with session id {node_sid} was not found') @@ -215,7 +215,7 @@ def _doop(self, callback: Optional[Callable[[UndoableOperation, OperationComplet def _undoop(self, callback: Optional[Callable[[UndoableOperation], None]] = None): for node_sid, new_pos, old_pos in self.__node_info: - node_id = self.__scene._session_node_id_to_id(node_sid) + node_id = self.__scene.session_node_id_to_id(node_sid) node = self.__scene.get_node(node_id) if node is None: raise OperationError(f'node with session id {node_sid} was not found') @@ -237,8 +237,8 @@ def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, o def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'add connection') - out_id = self.scene()._session_node_id_to_id(self.__out_sid) - in_id = self.scene()._session_node_id_to_id(self.__in_sid) + out_id = self.scene().session_node_id_to_id(self.__out_sid) + in_id = self.scene().session_node_id_to_id(self.__in_sid) if out_id is None or in_id is None \ or self.scene().get_node(out_id) is None \ or self.scene().get_node(in_id) is None: @@ -249,8 +249,8 @@ def _my_do_longop(self, longop: LongOperation): def _my_undo_longop(self, longop: LongOperation): longop.set_op_status(None, 'undo add connection') - out_id = self.scene()._session_node_id_to_id(self.__out_sid) - in_id = self.scene()._session_node_id_to_id(self.__in_sid) + out_id = self.scene().session_node_id_to_id(self.__out_sid) + in_id = self.scene().session_node_id_to_id(self.__in_sid) if out_id is None or in_id is None \ or self.scene().get_node(out_id) is None \ or self.scene().get_node(in_id) is None: @@ -277,8 +277,8 @@ def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, o def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'remove connection') - out_id = self.scene()._session_node_id_to_id(self.__out_sid) - in_id = self.scene()._session_node_id_to_id(self.__in_sid) + out_id = self.scene().session_node_id_to_id(self.__out_sid) + in_id = self.scene().session_node_id_to_id(self.__in_sid) if out_id is None or in_id is None \ or self.scene().get_node(out_id) is None \ or self.scene().get_node(in_id) is None: @@ -299,8 +299,8 @@ def _my_do_longop(self, longop: LongOperation): def _my_undo_longop(self, longop: LongOperation): longop.set_op_status(None, 'undo remove connection') - out_id = self.scene()._session_node_id_to_id(self.__out_sid) - in_id = self.scene()._session_node_id_to_id(self.__in_sid) + out_id = self.scene().session_node_id_to_id(self.__out_sid) + in_id = self.scene().session_node_id_to_id(self.__in_sid) if out_id is None or in_id is None \ or self.scene().get_node(out_id) is None \ or self.scene().get_node(in_id) is None: @@ -326,7 +326,7 @@ def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, n super().__init__(scene, data_controller) self.__param_name = parameter_name node_sid = node.get_session_id() - param = scene.get_node(scene._session_node_id_to_id(node_sid)).get_nodeui().parameter(parameter_name) + param = scene.get_node(scene.session_node_id_to_id(node_sid)).get_nodeui().parameter(parameter_name) self.__old_value = param.unexpanded_value() if new_value is not ... else ... self.__old_expression = param.expression() if new_expression is not ... else ... self.__new_value = new_value @@ -335,7 +335,7 @@ def __init__(self, scene: GraphicsScene, data_controller: SceneDataController, n def _my_do_longop(self, longop: LongOperation): longop.set_op_status(None, 'change parameter value') - node_id = self.scene()._session_node_id_to_id(self.__node_sid) + node_id = self.scene().session_node_id_to_id(self.__node_sid) param = self.scene().get_node(node_id).get_nodeui().parameter(self.__param_name) try: if self.__new_value is not ...: @@ -354,7 +354,7 @@ def _my_do_longop(self, longop: LongOperation): def _my_undo_longop(self, longop: LongOperation): longop.set_op_status(None, 'undo change parameter value') - node_id = self.scene()._session_node_id_to_id(self.__node_sid) + node_id = self.scene().session_node_id_to_id(self.__node_sid) param = self.scene().get_node(node_id).get_nodeui().parameter(self.__param_name) if self.__old_value is not ...: param.set_value(self.__old_value)