config editor changes
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"individual": [
|
||||||
|
{ "name": "Start Node", "type": "begin" },
|
||||||
|
{
|
||||||
|
"name": "Hybrid Processor",
|
||||||
|
"type": "middle",
|
||||||
|
"fields": [
|
||||||
|
{"label": "Threshold", "type": "number"},
|
||||||
|
{"label": "Tag", "type": "string"},
|
||||||
|
{"label": "Sub-Logic", "type": "slot"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "name": "Export Node", "type": "end" },
|
||||||
|
{ "name": "like this", "type": "logic" }
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -34,11 +34,11 @@ from pose_worker import run_pose_analysis
|
|||||||
from batch_processing import BatchProcessorDialog
|
from batch_processing import BatchProcessorDialog
|
||||||
|
|
||||||
import PySide6
|
import PySide6
|
||||||
from PySide6.QtWidgets import (QApplication, QDoubleSpinBox, QGraphicsItem, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QProgressDialog, QStyleOptionGraphicsItem, QTabBar, QWidget, QVBoxLayout, QGraphicsView, QGraphicsScene,
|
from PySide6.QtWidgets import (QApplication, QDoubleSpinBox, QFormLayout, QGraphicsItem, QGraphicsProxyWidget, QGraphicsTextItem, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QProgressDialog, QSizePolicy, QStyleOptionGraphicsItem, QTabBar, QWidget, QVBoxLayout, QGraphicsView, QGraphicsScene,
|
||||||
QHBoxLayout, QSplitter, QLabel, QPushButton, QComboBox, QInputDialog,
|
QHBoxLayout, QSplitter, QLabel, QPushButton, QComboBox, QInputDialog, QGraphicsRectItem,
|
||||||
QFileDialog, QScrollArea, QMessageBox, QSlider, QTextEdit, QGroupBox, QGridLayout, QCheckBox, QTabWidget, QProgressBar)
|
QFileDialog, QScrollArea, QMessageBox, QSlider, QTextEdit, QGroupBox, QGridLayout, QCheckBox, QTabWidget, QProgressBar)
|
||||||
from PySide6.QtCore import QEvent, Qt, QThread, Signal, QUrl, QRectF, QPointF, QRect, QSizeF, QTimer
|
from PySide6.QtCore import QEvent, Qt, QThread, Signal, QUrl, QRectF, QPointF, QRect, QSizeF, QTimer
|
||||||
from PySide6.QtGui import QCursor, QGuiApplication, QPainter, QColor, QFont, QPen, QBrush, QAction, QKeySequence, QIcon, QTextOption, QImage, QPixmap
|
from PySide6.QtGui import QCursor, QDoubleValidator, QGuiApplication, QPainter, QColor, QFont, QPen, QBrush, QAction, QKeySequence, QIcon, QTextOption, QImage, QPixmap, QTransform
|
||||||
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
|
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
|
||||||
from PySide6.QtMultimediaWidgets import QGraphicsVideoItem
|
from PySide6.QtMultimediaWidgets import QGraphicsVideoItem
|
||||||
|
|
||||||
@@ -2008,13 +2008,16 @@ class PremiereWindow(QMainWindow):
|
|||||||
train_act = QAction("Train Model", self)
|
train_act = QAction("Train Model", self)
|
||||||
train_act.triggered.connect(self.open_train_model_dialog)
|
train_act.triggered.connect(self.open_train_model_dialog)
|
||||||
|
|
||||||
config_act = QAction("Configuration Editor", self)
|
config_act = QAction("Configuration (Ind.)", self)
|
||||||
config_act.triggered.connect(self.open_model_configuration_tab)
|
config_act.triggered.connect(lambda: self.open_model_configuration_tab("individual"))
|
||||||
|
|
||||||
|
config_act2 = QAction("Configuration (Group)", self)
|
||||||
|
config_act2.triggered.connect(lambda: self.open_model_configuration_tab("group"))
|
||||||
|
|
||||||
menu.addAction(load_act)
|
menu.addAction(load_act)
|
||||||
menu.addAction(train_act)
|
menu.addAction(train_act)
|
||||||
menu.addAction(config_act)
|
menu.addAction(config_act)
|
||||||
|
menu.addAction(config_act2)
|
||||||
|
|
||||||
# Show the menu right under the mouse cursor
|
# Show the menu right under the mouse cursor
|
||||||
menu.exec(QCursor.pos())
|
menu.exec(QCursor.pos())
|
||||||
@@ -2090,8 +2093,8 @@ class PremiereWindow(QMainWindow):
|
|||||||
self.train_window.btn_train.clicked.connect(self.handle_start_training)
|
self.train_window.btn_train.clicked.connect(self.handle_start_training)
|
||||||
self.train_window.show()
|
self.train_window.show()
|
||||||
|
|
||||||
def open_model_configuration_tab(self):
|
def open_model_configuration_tab(self, stringy):
|
||||||
self.handle_model_configuration_session()
|
self.handle_model_configuration_session(stringy)
|
||||||
|
|
||||||
def open_export_data_dialog(self):
|
def open_export_data_dialog(self):
|
||||||
if self.export_window is None or not self.export_window.isVisible():
|
if self.export_window is None or not self.export_window.isVisible():
|
||||||
@@ -2169,11 +2172,11 @@ class PremiereWindow(QMainWindow):
|
|||||||
self.tabs.setCurrentIndex(new_idx)
|
self.tabs.setCurrentIndex(new_idx)
|
||||||
|
|
||||||
|
|
||||||
def handle_model_configuration_session(self):
|
def handle_model_configuration_session(self, stringy):
|
||||||
new_tab = ModelParameterConfigurationTab()
|
new_tab = ModelParameterConfigurationTab(stringy)
|
||||||
|
|
||||||
# 4. Handle Tab Placement (Keep '+' at the end)
|
# 4. Handle Tab Placement (Keep '+' at the end)
|
||||||
tab_name = "Configuration Editor"
|
tab_name = f"Configuration Editor - {stringy}"
|
||||||
plus_idx = self.tabs.count() - 1
|
plus_idx = self.tabs.count() - 1
|
||||||
new_idx = self.tabs.insertTab(plus_idx, new_tab, tab_name)
|
new_idx = self.tabs.insertTab(plus_idx, new_tab, tab_name)
|
||||||
|
|
||||||
@@ -3161,7 +3164,7 @@ from PySide6.QtWidgets import QGraphicsItem, QGraphicsSimpleTextItem, QInputDial
|
|||||||
|
|
||||||
|
|
||||||
class PuzzleBlock(QGraphicsPathItem):
|
class PuzzleBlock(QGraphicsPathItem):
|
||||||
def __init__(self, b_type, label, parent_item=None):
|
def __init__(self, b_type, label, parent_item=None, fields=None):
|
||||||
super().__init__(parent_item)
|
super().__init__(parent_item)
|
||||||
self.b_type = b_type # "begin", "middle", "end"
|
self.b_type = b_type # "begin", "middle", "end"
|
||||||
self.label_text = label
|
self.label_text = label
|
||||||
@@ -3178,19 +3181,108 @@ class PuzzleBlock(QGraphicsPathItem):
|
|||||||
QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
||||||
)
|
)
|
||||||
|
|
||||||
self.update_path()
|
self.setData(0, "p_block")
|
||||||
|
|
||||||
|
self.inputs = {}
|
||||||
|
self.original_inputs = {}
|
||||||
|
|
||||||
|
print(f"[DEBUG] - {fields}")
|
||||||
|
|
||||||
|
if fields:
|
||||||
|
self.container = QWidget()
|
||||||
|
self.container.setObjectName("blockContainer")
|
||||||
|
self.container.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||||
|
if self.b_type == "logic":
|
||||||
|
self.container.setFixedSize(130, 20)
|
||||||
|
self.container.setStyleSheet("background: transparent; border: none;")
|
||||||
|
else:
|
||||||
|
self.container.setFixedWidth(self.width)
|
||||||
|
self.container.setStyleSheet("background: transparent; color: white;")
|
||||||
|
|
||||||
|
# Use FormLayout for Side-by-Side placement
|
||||||
|
layout = QFormLayout(self.container)
|
||||||
|
layout.setContentsMargins(10, 5, 10, 5)
|
||||||
|
layout.setSpacing(5)
|
||||||
|
layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||||
|
layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||||
|
|
||||||
|
for f in fields:
|
||||||
|
if f['type'] in ["number", "string"]:
|
||||||
|
# Create the label
|
||||||
|
field_label = QLabel(f.get('label', 'Field:'))
|
||||||
|
field_label.setStyleSheet("font-size: 9px; font-weight: bold;")
|
||||||
|
|
||||||
|
# Create the input
|
||||||
|
edit = QLineEdit()
|
||||||
|
edit.setFixedHeight(18) # Keep it slim
|
||||||
|
edit.setStyleSheet("background: rgba(255, 255, 255, 0.2); border: 1px solid #aaa; color: white;")
|
||||||
|
|
||||||
|
if f['type'] == "number":
|
||||||
|
edit.setValidator(QDoubleValidator())
|
||||||
|
|
||||||
|
# Add to the form: Label is left, Edit is right
|
||||||
|
layout.addRow(field_label, edit)
|
||||||
|
self.inputs[f.get('label')] = edit
|
||||||
|
|
||||||
|
elif f['type'] == "slot":
|
||||||
|
slot_label = QLabel("")
|
||||||
|
slot_label.setStyleSheet("""
|
||||||
|
color: #FFA500;
|
||||||
|
font-size: 10px;
|
||||||
|
border: 1px dashed #FFA500;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
min-height: 20px; /* CRITICAL: Gives the mouse something to hit */
|
||||||
|
background: rgba(255, 165, 0, 0.05); /* Slight tint helps visual hit-testing */
|
||||||
|
""")
|
||||||
|
# Force it to expand so it's not a tiny dot in the corner
|
||||||
|
slot_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
|
|
||||||
|
# Add as a full-width row
|
||||||
|
layout.addRow(slot_label)
|
||||||
|
# Make sure it's in the inputs dict so perform_snap can find the key
|
||||||
|
key = f.get('label', 'slot')
|
||||||
|
self.inputs[key] = slot_label
|
||||||
|
self.original_inputs[key] = slot_label
|
||||||
|
|
||||||
|
self.proxy = QGraphicsProxyWidget(self)
|
||||||
|
self.proxy.setZValue(10)
|
||||||
|
self.proxy.setWidget(self.container)
|
||||||
|
# Center the 160px container inside your (potentially) 180px block
|
||||||
|
# Or set self.width = 160 to match exactly
|
||||||
|
self.width = 160
|
||||||
|
self.proxy.setPos(0, 35)
|
||||||
|
|
||||||
|
# Calculate height based on the form's hint
|
||||||
|
self.height = 50 + layout.sizeHint().height()
|
||||||
|
|
||||||
# Color Coding
|
# Color Coding
|
||||||
colors = {"begin": "#2e7d32", "middle": "#1565c0", "end": "#c62828"}
|
self.update_path()
|
||||||
|
|
||||||
|
colors = {"begin": "#2e7d32", "middle": "#1565c0", "end": "#c62828", "logic": "#ef6c00"}
|
||||||
self.setBrush(QBrush(QColor(colors.get(b_type, "#555"))))
|
self.setBrush(QBrush(QColor(colors.get(b_type, "#555"))))
|
||||||
self.setPen(QPen(QColor("#ffffff"), 1))
|
self.setPen(QPen(QColor("#ffffff"), 1))
|
||||||
|
|
||||||
self.label_item = QGraphicsSimpleTextItem(self.label_text, self)
|
self.label_item = QGraphicsSimpleTextItem(self.label_text, self)
|
||||||
self.label_item.setBrush(QColor("white"))
|
self.label_item.setBrush(QColor("white"))
|
||||||
self.label_item.setPos(25, 15)
|
|
||||||
|
if self.b_type == "logic":
|
||||||
|
self.label_item.setPos(2, 1)
|
||||||
|
else:
|
||||||
|
self.label_item.setPos(25, 15)
|
||||||
|
|
||||||
|
|
||||||
def update_path(self):
|
def update_path(self):
|
||||||
|
"""This changes how they visually appear. Likely no logic broken here."""
|
||||||
self.prepareGeometryChange()
|
self.prepareGeometryChange()
|
||||||
|
|
||||||
|
if self.b_type == "logic":
|
||||||
|
display_width = 130 # Slimmer profile
|
||||||
|
display_height = 20 # Shorter default height
|
||||||
|
else:
|
||||||
|
display_width = self.width
|
||||||
|
display_height = self.height
|
||||||
|
|
||||||
path = QPainterPath()
|
path = QPainterPath()
|
||||||
path.moveTo(0, 0)
|
path.moveTo(0, 0)
|
||||||
|
|
||||||
@@ -3200,18 +3292,18 @@ class PuzzleBlock(QGraphicsPathItem):
|
|||||||
path.lineTo(30, 0)
|
path.lineTo(30, 0)
|
||||||
# Nub (Male) - Sweep is negative to curve OUTWARD
|
# Nub (Male) - Sweep is negative to curve OUTWARD
|
||||||
path.arcTo(30, -self.tab_size/2, self.tab_size, self.tab_size, 180, -180)
|
path.arcTo(30, -self.tab_size/2, self.tab_size, self.tab_size, 180, -180)
|
||||||
path.lineTo(self.width, 0)
|
path.lineTo(display_width, 0)
|
||||||
|
|
||||||
# 2. RIGHT EDGE
|
# 2. RIGHT EDGE
|
||||||
path.lineTo(self.width, self.height)
|
path.lineTo(display_width, display_height)
|
||||||
|
|
||||||
# 3. BOTTOM EDGE
|
# 3. BOTTOM EDGE
|
||||||
# Begin and Middle blocks now have a "Hole" (Female) on bottom
|
# Begin and Middle blocks now have a "Hole" (Female) on bottom
|
||||||
if self.b_type in ["begin", "middle"]:
|
if self.b_type in ["begin", "middle"]:
|
||||||
path.lineTo(30 + self.tab_size, self.height)
|
path.lineTo(30 + self.tab_size, display_height)
|
||||||
# Socket (Female) - Sweep is positive to curve INWARD
|
# Socket (Female) - Sweep is positive to curve INWARD
|
||||||
path.arcTo(30, self.height - self.tab_size/2, self.tab_size, self.tab_size, 0, 180)
|
path.arcTo(30, display_height - self.tab_size/2, self.tab_size, self.tab_size, 0, 180)
|
||||||
path.lineTo(0, self.height)
|
path.lineTo(0, display_height)
|
||||||
|
|
||||||
# 4. LEFT EDGE
|
# 4. LEFT EDGE
|
||||||
path.lineTo(0, 0)
|
path.lineTo(0, 0)
|
||||||
@@ -3219,7 +3311,9 @@ class PuzzleBlock(QGraphicsPathItem):
|
|||||||
path.closeSubpath()
|
path.closeSubpath()
|
||||||
self.setPath(path)
|
self.setPath(path)
|
||||||
|
|
||||||
|
|
||||||
def mouseDoubleClickEvent(self, event):
|
def mouseDoubleClickEvent(self, event):
|
||||||
|
'''Simply to edit the name of a start box.'''
|
||||||
if self.b_type == "begin":
|
if self.b_type == "begin":
|
||||||
new_name, ok = QInputDialog.getText(None, "Rename Step", "Enter name:", text=self.label_text)
|
new_name, ok = QInputDialog.getText(None, "Rename Step", "Enter name:", text=self.label_text)
|
||||||
if ok and new_name:
|
if ok and new_name:
|
||||||
@@ -3227,63 +3321,94 @@ class PuzzleBlock(QGraphicsPathItem):
|
|||||||
self.label_item.setText(new_name)
|
self.label_item.setText(new_name)
|
||||||
super().mouseDoubleClickEvent(event)
|
super().mouseDoubleClickEvent(event)
|
||||||
|
|
||||||
def detach_from_neighbors(self):
|
|
||||||
"""Sever logical connections safely so we don't leave ghost references."""
|
|
||||||
if self.parent_block:
|
|
||||||
self.parent_block.child_block = None
|
|
||||||
self.parent_block = None
|
|
||||||
if self.child_block:
|
|
||||||
self.child_block.parent_block = None
|
|
||||||
self.child_block = None
|
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
# Only handle visual layering here. Do NOT detach links.
|
# Only handle visual layering here. Do NOT detach links.
|
||||||
self.setZValue(100)
|
self.setZValue(100)
|
||||||
super().mousePressEvent(event)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event):
|
def mouseReleaseEvent(self, event):
|
||||||
# Drop it back down to standard Z-level
|
# Drop it back down to standard Z-level
|
||||||
self.setZValue(0)
|
self.setZValue(0)
|
||||||
super().mouseReleaseEvent(event)
|
super().mouseReleaseEvent(event)
|
||||||
|
|
||||||
|
|
||||||
def itemChange(self, change, value):
|
def itemChange(self, change, value):
|
||||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||||
# If this block is moving, check if its neighbors are NOT moving with it.
|
if self.scene() and self.scene().mouseGrabberItem() is self:
|
||||||
# If a neighbor is stationary while we move, the link must break.
|
|
||||||
|
|
||||||
# 1. Check Parent Link
|
|
||||||
if self.parent_block and not self.parent_block.isSelected():
|
|
||||||
print(f"DEBUG: Tearing away from stationary parent: {self.parent_block.label_text}")
|
|
||||||
self.parent_block.child_block = None
|
|
||||||
self.parent_block = None
|
|
||||||
|
|
||||||
# 2. Check Child Link
|
|
||||||
if self.child_block and not self.child_block.isSelected():
|
|
||||||
print(f"DEBUG: Tearing away from stationary child: {self.child_block.label_text}")
|
|
||||||
self.child_block.parent_block = None
|
|
||||||
self.child_block = None
|
|
||||||
|
|
||||||
|
# 1. Sever Parent Link (Both ways)
|
||||||
|
if self.parent_block:
|
||||||
|
print(f"DEBUG: Tearing away from parent: {self.parent_block.label_text}")
|
||||||
|
self.parent_block.child_block = None # Tell parent I'm gone
|
||||||
|
self.parent_block = None # Forget my parent
|
||||||
|
|
||||||
|
# 2. Sever Child Link (Both ways)
|
||||||
|
if self.child_block and not self.child_block.isSelected():
|
||||||
|
print(f"DEBUG: Tearing away from stationary child: {self.child_block.label_text}")
|
||||||
|
self.child_block.parent_block = None # Tell child I'm gone
|
||||||
|
self.child_block = None # Forget my child
|
||||||
|
|
||||||
return super().itemChange(change, value)
|
return super().itemChange(change, value)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class BlockLibrary(QListWidget):
|
class BlockLibrary(QListWidget):
|
||||||
def __init__(self, parent_tab):
|
def __init__(self, parent_tab, stringy):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setDragEnabled(True)
|
self.setDragEnabled(True)
|
||||||
self.add_item("Begin Event", "begin")
|
|
||||||
self.add_item("Process Step", "middle")
|
self.mode = stringy
|
||||||
self.add_item("Finish/Export", "end")
|
|
||||||
self.parent_tab = parent_tab # Reference to main tab to access canvas
|
self.parent_tab = parent_tab # Reference to main tab to access canvas
|
||||||
|
|
||||||
def add_item(self, label, block_type):
|
self.load_blocks_from_json("blocks.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_blocks_from_json(self, file_path):
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
print(f"Error: {file_path} not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Pull the list assigned to the current mode (e.g., "individual")
|
||||||
|
blocks = data.get(self.mode, [])
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
# NEW: Get fields if they exist, otherwise empty list
|
||||||
|
fields = block.get('fields', [])
|
||||||
|
self.add_item(block['name'], block['type'], fields)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load block library: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def add_item(self, label, block_type, fields):
|
||||||
item = QListWidgetItem(label)
|
item = QListWidgetItem(label)
|
||||||
item.setData(Qt.UserRole, block_type)
|
item.setData(Qt.UserRole, block_type)
|
||||||
|
# NEW: Store fields list in the item
|
||||||
|
item.setData(Qt.UserRole + 1, fields)
|
||||||
self.addItem(item)
|
self.addItem(item)
|
||||||
|
|
||||||
|
|
||||||
def startDrag(self, supportedActions):
|
def startDrag(self, supportedActions):
|
||||||
item = self.currentItem()
|
item = self.currentItem()
|
||||||
|
if not item: return
|
||||||
|
|
||||||
mime_data = QMimeData()
|
mime_data = QMimeData()
|
||||||
# Pass the type and label as string
|
|
||||||
mime_data.setText(f"{item.data(Qt.UserRole)}|{item.text()}")
|
# NEW: We must include the fields in the drag data!
|
||||||
|
# Format: type | label | fields_as_json_string
|
||||||
|
b_type = item.data(Qt.UserRole)
|
||||||
|
label = item.text()
|
||||||
|
fields = item.data(Qt.UserRole + 1)
|
||||||
|
fields_str = json.dumps(fields) # Convert list to string for transfer
|
||||||
|
|
||||||
|
mime_data.setText(f"{b_type}|{label}|{fields_str}")
|
||||||
|
|
||||||
drag = QDrag(self)
|
drag = QDrag(self)
|
||||||
drag.setMimeData(mime_data)
|
drag.setMimeData(mime_data)
|
||||||
@@ -3293,9 +3418,13 @@ class BlockLibrary(QListWidget):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import QGraphicsLineItem, QGraphicsRectItem, QGraphicsSimpleTextItem
|
from PySide6.QtWidgets import QGraphicsLineItem, QGraphicsRectItem, QGraphicsSimpleTextItem
|
||||||
from PySide6.QtGui import QPen, QColor, QBrush
|
from PySide6.QtGui import QPen, QColor, QBrush
|
||||||
|
|
||||||
|
|
||||||
class TopologyCanvas(QGraphicsView):
|
class TopologyCanvas(QGraphicsView):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.scene = QGraphicsScene()
|
self.scene = QGraphicsScene()
|
||||||
@@ -3303,58 +3432,97 @@ class TopologyCanvas(QGraphicsView):
|
|||||||
self.setAcceptDrops(True)
|
self.setAcceptDrops(True)
|
||||||
self.scene.setSceneRect(0, 0, 2000, 2000)
|
self.scene.setSceneRect(0, 0, 2000, 2000)
|
||||||
|
|
||||||
self.chain = [] # Track items if needed
|
|
||||||
|
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
item = self.itemAt(event.position().toPoint())
|
||||||
|
# Toggle drag mode based on whether an item was clicked
|
||||||
|
mode = QGraphicsView.DragMode.NoDrag if item else QGraphicsView.DragMode.RubberBandDrag
|
||||||
|
self.setDragMode(mode)
|
||||||
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
#needed?
|
||||||
|
self.chain = []
|
||||||
|
self._processing_release = False
|
||||||
|
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event):
|
def mouseReleaseEvent(self, event):
|
||||||
# 1. Native release
|
# Let the native C++ event finish first
|
||||||
super().mouseReleaseEvent(event)
|
super().mouseReleaseEvent(event)
|
||||||
|
|
||||||
if getattr(self, '_processing_release', False):
|
if self._processing_release:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Capture the data we need
|
||||||
|
global_pos = event.globalPosition().toPoint()
|
||||||
|
scene_pos = self.mapToScene(event.position().toPoint())
|
||||||
|
|
||||||
|
# Defer the logic to the next event loop cycle (stops the DLL crash)
|
||||||
|
QTimer.singleShot(0, lambda: self.safe_process_release(global_pos, scene_pos))
|
||||||
|
|
||||||
|
def safe_process_release(self, global_pos, scene_pos):
|
||||||
self._processing_release = True
|
self._processing_release = True
|
||||||
# ---------------------------
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# 1. Cleaner Trash Check (Assume you have a ref to your sidebar)
|
||||||
# 2. Sidebar Trash Logic
|
# Replacing the 'while widget' loop with a geometry check is much safer
|
||||||
global_pos = event.globalPosition().toPoint()
|
if hasattr(self, 'library_ref') and self.library_ref.rect().contains(self.library_ref.mapFromGlobal(global_pos)):
|
||||||
widget_under_mouse = QApplication.widgetAt(global_pos)
|
print("Action: Trashing items")
|
||||||
|
|
||||||
print("huh")
|
|
||||||
|
|
||||||
is_over_library = False
|
|
||||||
current_widget = widget_under_mouse
|
|
||||||
while current_widget:
|
|
||||||
if isinstance(current_widget, BlockLibrary):
|
|
||||||
is_over_library = True
|
|
||||||
break
|
|
||||||
current_widget = current_widget.parentWidget()
|
|
||||||
|
|
||||||
if is_over_library:
|
|
||||||
print("Action: Trashing selected items")
|
|
||||||
for item in self.scene.selectedItems():
|
for item in self.scene.selectedItems():
|
||||||
if isinstance(item, PuzzleBlock):
|
self.scene.removeItem(item)
|
||||||
self.scene.removeItem(item)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
item_under_mouse = self.itemAt(event.position().toPoint())
|
# 2. Logic for Snapping
|
||||||
|
items_at_pos = self.scene.items(scene_pos)
|
||||||
if isinstance(item_under_mouse, PuzzleBlock):
|
driver = next((i for i in items_at_pos if isinstance(i, PuzzleBlock) and i.isSelected()), None)
|
||||||
print(f"DEBUG: Snapping initiated via {item_under_mouse.label_text}")
|
target = next((i for i in items_at_pos if isinstance(i, PuzzleBlock) and not i.isSelected()), None)
|
||||||
mouse_scene_pos = self.mapToScene(event.position().toPoint())
|
|
||||||
self.perform_snap(item_under_mouse, mouse_scene_pos)
|
|
||||||
else:
|
|
||||||
print("DEBUG: Drop in empty space. Maintaining current stack integrity.")
|
|
||||||
|
|
||||||
self.validate_topology()
|
|
||||||
|
|
||||||
|
|
||||||
|
if driver and target:
|
||||||
|
self.perform_snap(driver, scene_pos)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Always reset the guard so the next real click works
|
|
||||||
self._processing_release = False
|
self._processing_release = False
|
||||||
|
|
||||||
|
|
||||||
|
def dragEnterEvent(self, event):
|
||||||
|
if event.mimeData().hasText():
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
def dragMoveEvent(self, event):
|
||||||
|
if event.mimeData().hasText():
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
|
||||||
|
def dropEvent(self, event):
|
||||||
|
raw_data = event.mimeData().text()
|
||||||
|
parts = raw_data.split("|")
|
||||||
|
if len(parts) < 2: return # Safety check
|
||||||
|
|
||||||
|
block_type, label = parts[0], parts[1]
|
||||||
|
fields = []
|
||||||
|
|
||||||
|
# Safe JSON parsing
|
||||||
|
if len(parts) > 2:
|
||||||
|
try:
|
||||||
|
fields = json.loads(parts[2])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DEBUG: Failed to parse: {e}")
|
||||||
|
|
||||||
|
# Spawn selection
|
||||||
|
if block_type == "value":
|
||||||
|
# new_block = ValueBlock(value="0")
|
||||||
|
print("Spawned ValueBlock (Placeholder)")
|
||||||
|
return # Exit early if not implemented to avoid setPos error
|
||||||
|
else:
|
||||||
|
new_block = PuzzleBlock(block_type, label, fields=fields)
|
||||||
|
|
||||||
|
self.scene.addItem(new_block)
|
||||||
|
|
||||||
|
# Try snapping, otherwise place at drop location
|
||||||
|
if not self.perform_snap(new_block, self.mapToScene(event.position().toPoint())):
|
||||||
|
new_block.setPos(self.mapToScene(event.position().toPoint()))
|
||||||
|
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
|
||||||
def get_stack_extremity(self, block, direction):
|
def get_stack_extremity(self, block, direction):
|
||||||
curr = block
|
curr = block
|
||||||
@@ -3369,49 +3537,126 @@ class TopologyCanvas(QGraphicsView):
|
|||||||
return curr
|
return curr
|
||||||
|
|
||||||
|
|
||||||
|
def keyPressEvent(self, event):
|
||||||
|
if event.key() == Qt.Key.Key_F7:
|
||||||
|
self.diagnostic_dump()
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
def diagnostic_dump(self):
|
||||||
|
print(f"\n{'-'*20} LOGICAL STATE DUMP {'-'*20}")
|
||||||
|
blocks = [item for item in self.scene.items() if hasattr(item, 'data') and item.data(0) == "p_block"]
|
||||||
|
|
||||||
|
if not blocks:
|
||||||
|
print("No blocks found in scene.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for i, block in enumerate(blocks):
|
||||||
|
parent_name = block.parent_block.label_text if block.parent_block else "None"
|
||||||
|
child_name = block.child_block.label_text if block.child_block else "None"
|
||||||
|
|
||||||
|
# Check for 'Half-Broken' links (Asymmetry)
|
||||||
|
warning = ""
|
||||||
|
if block.parent_block and block.parent_block.child_block is not block:
|
||||||
|
warning = " [!] ASYMMETRY: Parent doesn't recognize this child"
|
||||||
|
if block.child_block and block.child_block.parent_block is not block:
|
||||||
|
warning = " [!] ASYMMETRY: Child doesn't recognize this parent"
|
||||||
|
|
||||||
|
print(f"[{i}] BLOCK: {block.label_text}")
|
||||||
|
print(f" - Parent: {parent_name}")
|
||||||
|
print(f" - Child: {child_name}{warning}")
|
||||||
|
print(f" - Selected: {block.isSelected()}")
|
||||||
|
print(f"{'-'*60}\n")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def perform_snap(self, dragged_item, mouse_pos):
|
def perform_snap(self, dragged_item, mouse_pos):
|
||||||
print(f"\n{'='*60}\nSNAP ATTEMPT: {dragged_item.label_text} ({dragged_item.b_type})")
|
print(f"\n{'='*60}\n[DEBUG] STARTING SNAP: {dragged_item.label_text}")
|
||||||
|
print(f"[DEBUG] Mouse Release Pos: {mouse_pos}")
|
||||||
|
|
||||||
|
potential_targets = self.scene.items(mouse_pos)
|
||||||
|
target_block = None
|
||||||
|
|
||||||
# 1. Identify target block on canvas
|
for item in potential_targets:
|
||||||
search_rect = QRectF(mouse_pos.x() - 10, mouse_pos.y() - 10, 20, 20)
|
# Ignore the item we are dragging
|
||||||
items = self.scene.items(search_rect)
|
if item is dragged_item:
|
||||||
|
continue
|
||||||
entry_target = None
|
|
||||||
for item in items:
|
# PROXY CHECK: If it's a proxy, we want the block it belongs to
|
||||||
if isinstance(item, PuzzleBlock) and not item.isSelected():
|
# Instead of walking up, check if the item itself IS the block
|
||||||
entry_target = item
|
if item.data(0) == "p_block":
|
||||||
|
target_block = item
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# If it's a child of a block (like a label), get the parent safely
|
||||||
|
p = item.parentItem()
|
||||||
|
if p and p.data(0) == "p_block":
|
||||||
|
target_block = p
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_block:
|
||||||
|
return False
|
||||||
|
|
||||||
if not entry_target:
|
# 1. Find the ultimate roots of both stacks
|
||||||
print(f"DEBUG: No valid target under mouse at {mouse_pos}. Snap failed.")
|
dragged_root = self.get_stack_extremity(dragged_item, "up")
|
||||||
|
dragged_family = set()
|
||||||
|
curr = dragged_root
|
||||||
|
while curr:
|
||||||
|
dragged_family.add(curr)
|
||||||
|
curr = curr.child_block
|
||||||
|
if curr == dragged_root: break
|
||||||
|
|
||||||
|
# 2. Find a target that is NOT in that family
|
||||||
|
potential_targets = self.scene.items(mouse_pos)
|
||||||
|
target_block = None
|
||||||
|
|
||||||
|
for item in potential_targets:
|
||||||
|
# Safely get the block object
|
||||||
|
candidate = None
|
||||||
|
if item.data(0) == "p_block":
|
||||||
|
candidate = item
|
||||||
|
else:
|
||||||
|
p = item.parentItem()
|
||||||
|
if p and p.data(0) == "p_block":
|
||||||
|
candidate = p
|
||||||
|
|
||||||
|
# CRITICAL: Target cannot be part of the stack we are holding
|
||||||
|
if candidate and candidate not in dragged_family:
|
||||||
|
target_block = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_block:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print(f"DEBUG: Entry target identified: {entry_target.label_text} ({entry_target.b_type}) at {entry_target.scenePos()}")
|
# 3. Double-check logical roots (The "Atomic" check)
|
||||||
|
target_root = self.get_stack_extremity(target_block, "up")
|
||||||
|
if dragged_root == target_root:
|
||||||
|
print(f"DEBUG: Still shares root {dragged_root.label_text}")
|
||||||
|
return False
|
||||||
|
|
||||||
# 2. Find the head/tail of the targeted stack
|
|
||||||
head = self.get_stack_extremity(entry_target, "up")
|
|
||||||
tail = self.get_stack_extremity(entry_target, "down")
|
print(f"[DEBUG] Processing stack-to-stack connection: {dragged_item.label_text} -> {target_block.label_text}")
|
||||||
print(f"DEBUG: Stack Extremities -> Head: {head.label_text} | Tail: {tail.label_text}")
|
|
||||||
|
head = self.get_stack_extremity(target_block, "up")
|
||||||
|
tail = self.get_stack_extremity(target_block, "down")
|
||||||
|
print(f"[DEBUG] Stack Extremities -> Head: {head.label_text} | Tail: {tail.label_text}")
|
||||||
|
|
||||||
# CASE 1: Snap ABOVE the stack (Dragged item becomes PARENT)
|
|
||||||
# Condition: Dragged item isn't a Finish, and the stack head is open.
|
|
||||||
if dragged_item.b_type != "end" and head.parent_block is None:
|
if dragged_item.b_type != "end" and head.parent_block is None:
|
||||||
if head.b_type != "begin":
|
if head.b_type != "begin":
|
||||||
print(f"DEBUG: Logic Trigger -> Snap {dragged_item.label_text} ABOVE {head.label_text}")
|
print(f"[DEBUG] Snap Trigger: {dragged_item.label_text} ABOVE {head.label_text}")
|
||||||
return self.connect_blocks(dragged_item, head, mode="upward")
|
return self.connect_blocks(dragged_item, head, mode="upward")
|
||||||
else:
|
else:
|
||||||
print("DEBUG: Snap Rejected -> Cannot snap above a 'Begin' block.")
|
print("[DEBUG] SNAP REJECTED: Cannot snap above a 'Begin' block.")
|
||||||
|
|
||||||
# CASE 2: Snap BELOW the stack (Dragged item becomes CHILD)
|
|
||||||
# Condition: Dragged item isn't a Begin, and the stack tail is open.
|
|
||||||
if dragged_item.b_type != "begin" and tail.child_block is None:
|
if dragged_item.b_type != "begin" and tail.child_block is None:
|
||||||
if tail.b_type != "end":
|
if tail.b_type != "end":
|
||||||
print(f"DEBUG: Logic Trigger -> Snap {dragged_item.label_text} BELOW {tail.label_text}")
|
print(f"[DEBUG] Snap Trigger: {dragged_item.label_text} BELOW {tail.label_text}")
|
||||||
return self.connect_blocks(tail, dragged_item, mode="downward")
|
return self.connect_blocks(tail, dragged_item, mode="downward")
|
||||||
else:
|
else:
|
||||||
print("DEBUG: Snap Rejected -> Cannot snap below a 'Finish' block.")
|
print("[DEBUG] SNAP REJECTED: Cannot snap below a 'Finish' block.")
|
||||||
|
|
||||||
print("DEBUG: No valid connection rules met for this pair.")
|
print(f"[DEBUG] SNAP FAILED: {dragged_item.label_text} found no valid connection point on {target_block.label_text}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -3424,7 +3669,6 @@ class TopologyCanvas(QGraphicsView):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if mode == "upward":
|
if mode == "upward":
|
||||||
# The 'child' (already on canvas) is the anchor
|
|
||||||
anchor_pos = child.scenePos()
|
anchor_pos = child.scenePos()
|
||||||
if anchor_pos.isNull():
|
if anchor_pos.isNull():
|
||||||
print(f"DEBUG: UPWARD ERROR -> {child.label_text} scenePos is Null. Aborting.")
|
print(f"DEBUG: UPWARD ERROR -> {child.label_text} scenePos is Null. Aborting.")
|
||||||
@@ -3435,7 +3679,6 @@ class TopologyCanvas(QGraphicsView):
|
|||||||
print(f"DEBUG: Physical Move -> {parent.label_text} set to {new_parent_pos} (Above {child.label_text})")
|
print(f"DEBUG: Physical Move -> {parent.label_text} set to {new_parent_pos} (Above {child.label_text})")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# The 'parent' (already on canvas) is the anchor
|
|
||||||
anchor_pos = parent.scenePos()
|
anchor_pos = parent.scenePos()
|
||||||
if anchor_pos.isNull():
|
if anchor_pos.isNull():
|
||||||
print(f"DEBUG: DOWNWARD ERROR -> {parent.label_text} scenePos is Null. Aborting.")
|
print(f"DEBUG: DOWNWARD ERROR -> {parent.label_text} scenePos is Null. Aborting.")
|
||||||
@@ -3445,122 +3688,50 @@ class TopologyCanvas(QGraphicsView):
|
|||||||
child.setPos(new_child_pos)
|
child.setPos(new_child_pos)
|
||||||
print(f"DEBUG: Physical Move -> {child.label_text} set to {new_child_pos} (Below {parent.label_text})")
|
print(f"DEBUG: Physical Move -> {child.label_text} set to {new_child_pos} (Below {parent.label_text})")
|
||||||
|
|
||||||
# Logical Link
|
|
||||||
parent.child_block = child
|
parent.child_block = child
|
||||||
child.parent_block = parent
|
child.parent_block = parent
|
||||||
print(f"DEBUG: Logical Link Established -> {parent.label_text}.child = {child.label_text}")
|
print(f"DEBUG: Logical Link Established -> {parent.label_text}.child = {child.label_text}")
|
||||||
|
|
||||||
# Chain Sync
|
|
||||||
self.ripple_move(child)
|
self.ripple_move(child)
|
||||||
print(f"SUCCESS: {parent.label_text} and {child.label_text} are now connected.")
|
print(f"SUCCESS: {parent.label_text} and {child.label_text} are now connected.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def ripple_move(self, start_block):
|
def ripple_move(self, start_block):
|
||||||
"""Forces all downstream blocks to align with their parents."""
|
|
||||||
curr = start_block
|
curr = start_block
|
||||||
|
visited = set() # Track to prevent infinite loops
|
||||||
print(f"DEBUG: Starting Ripple Move from {start_block.label_text}")
|
print(f"DEBUG: Starting Ripple Move from {start_block.label_text}")
|
||||||
|
|
||||||
while curr and curr.child_block:
|
while curr and curr.child_block:
|
||||||
|
if curr in visited:
|
||||||
|
print("CRITICAL: Circular link detected in ripple! Breaking.")
|
||||||
|
break
|
||||||
|
visited.add(curr)
|
||||||
|
|
||||||
next_block = curr.child_block
|
next_block = curr.child_block
|
||||||
expected_pos = curr.scenePos() + QPointF(0, curr.height)
|
expected_pos = curr.scenePos() + QPointF(0, curr.height)
|
||||||
|
|
||||||
if next_block.scenePos() != expected_pos:
|
if next_block.scenePos() != expected_pos:
|
||||||
print(f"DEBUG: Ripple Sync -> Moving {next_block.label_text} to {expected_pos}")
|
print(f"DEBUG: Ripple Sync -> Moving {next_block.label_text} to {expected_pos}")
|
||||||
|
|
||||||
next_block.setPos(expected_pos)
|
next_block.setPos(expected_pos)
|
||||||
|
|
||||||
curr = next_block
|
curr = next_block
|
||||||
print("DEBUG: Ripple Move Complete.")
|
print("DEBUG: Ripple Move Complete.")
|
||||||
|
|
||||||
|
|
||||||
def keyPressEvent(self, event):
|
|
||||||
"""Catches the F7 key to print current logical graphs."""
|
|
||||||
if event.key() == Qt.Key_F7:
|
|
||||||
self.print_logical_graphs()
|
|
||||||
else:
|
|
||||||
# Ensure normal key events (like Ctrl for multi-select) still work
|
|
||||||
super().keyPressEvent(event)
|
|
||||||
|
|
||||||
def print_logical_graphs(self):
|
|
||||||
"""Crawls the logical links and prints all current stacks."""
|
|
||||||
print(f"\n{'='*60}\nCURRENT LOGICAL GRAPHS (F7 Triggered)")
|
|
||||||
|
|
||||||
# Grab all puzzle blocks currently in the scene
|
|
||||||
blocks = [item for item in self.scene.items() if hasattr(item, 'b_type')]
|
|
||||||
|
|
||||||
# Find the 'Heads' (blocks that have no parent)
|
|
||||||
heads = [b for b in blocks if getattr(b, 'parent_block', None) is None]
|
|
||||||
|
|
||||||
if not heads:
|
|
||||||
print("No standalone blocks or stacks found on canvas.")
|
|
||||||
print(f"{'='*60}\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Trace down from every head to map the stacks
|
|
||||||
for i, head in enumerate(heads):
|
|
||||||
chain = []
|
|
||||||
curr = head
|
|
||||||
while curr:
|
|
||||||
# Format: [Label (type)]
|
|
||||||
chain.append(f"[{curr.label_text} ({curr.b_type})]")
|
|
||||||
curr = getattr(curr, 'child_block', None)
|
|
||||||
|
|
||||||
print(f"Stack {i + 1}: " + " -> ".join(chain))
|
|
||||||
|
|
||||||
print(f"{'='*60}\n")
|
|
||||||
|
|
||||||
def dropEvent(self, event):
|
|
||||||
raw_data = event.mimeData().text()
|
|
||||||
if "|" not in raw_data: return
|
|
||||||
|
|
||||||
if isinstance(event.source(), BlockLibrary):
|
|
||||||
block_type, label = raw_data.split("|")
|
|
||||||
drop_pos = self.mapToScene(event.position().toPoint())
|
|
||||||
|
|
||||||
new_block = PuzzleBlock(block_type, label)
|
|
||||||
# Add to scene first (invisible/no pos set yet)
|
|
||||||
self.scene.addItem(new_block)
|
|
||||||
|
|
||||||
# Attempt to snap it immediately
|
|
||||||
snapped = self.perform_snap(new_block, drop_pos)
|
|
||||||
|
|
||||||
# If it didn't snap to a stack, place it exactly at the mouse
|
|
||||||
if not snapped:
|
|
||||||
new_block.setPos(drop_pos)
|
|
||||||
|
|
||||||
self.validate_topology()
|
|
||||||
event.acceptProposedAction()
|
|
||||||
|
|
||||||
def dragEnterEvent(self, event):
|
|
||||||
if event.mimeData().hasText():
|
|
||||||
event.acceptProposedAction()
|
|
||||||
|
|
||||||
def dragMoveEvent(self, event):
|
|
||||||
if event.mimeData().hasText():
|
|
||||||
event.acceptProposedAction()
|
|
||||||
|
|
||||||
def validate_topology(self):
|
|
||||||
all_items = [i for i in self.scene.items() if isinstance(i, PuzzleBlock)]
|
|
||||||
has_begin = any(i.b_type == "begin" for i in all_items)
|
|
||||||
has_end = any(i.b_type == "end" for i in all_items)
|
|
||||||
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
if not has_begin:
|
|
||||||
warnings.append("⚠️ Missing 'Begin' block (No data source defined).")
|
|
||||||
if not has_end:
|
|
||||||
warnings.append("⚠️ Missing 'Finish' block (Data will not be saved).")
|
|
||||||
|
|
||||||
if warnings:
|
|
||||||
status_text = "\n".join(warnings)
|
|
||||||
#self.parent().info_label.setText(f"<font color='red'>{status_text}</font>")
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
#self.parent().info_label.setText("<font color='#00ff00'>✅ Configuration Valid</font>")
|
|
||||||
|
|
||||||
|
|
||||||
class ModelParameterConfigurationTab(QWidget):
|
class ModelParameterConfigurationTab(QWidget):
|
||||||
def __init__(self):
|
def __init__(self, stringy):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
self.mode = stringy
|
||||||
|
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet("""
|
||||||
QMainWindow, QWidget#centralWidget {
|
QMainWindow, QWidget#centralWidget {
|
||||||
@@ -3635,7 +3806,7 @@ class ModelParameterConfigurationTab(QWidget):
|
|||||||
sidebar_container = QWidget()
|
sidebar_container = QWidget()
|
||||||
sidebar_layout = QVBoxLayout(sidebar_container)
|
sidebar_layout = QVBoxLayout(sidebar_container)
|
||||||
|
|
||||||
self.block_library = BlockLibrary(parent_tab=self)
|
self.block_library = BlockLibrary(parent_tab=self, stringy=self.mode)
|
||||||
|
|
||||||
# Reuse your existing Inspector logic
|
# Reuse your existing Inspector logic
|
||||||
self.inspector_scroll = QScrollArea()
|
self.inspector_scroll = QScrollArea()
|
||||||
|
|||||||
Reference in New Issue
Block a user