improved project saving capabilities
This commit is contained in:
+423
-297
@@ -7,18 +7,17 @@ License: GPL-3.0
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
from pathlib import Path
|
||||
from copy import deepcopy
|
||||
from typing import Sequence, Any
|
||||
|
||||
import pandas as pd
|
||||
from pandas import DataFrame
|
||||
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QVBoxLayout, QWidget, QFrame, QSpinBox
|
||||
|
||||
from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListView, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, QFrame, QSpinBox, QFileDialog
|
||||
from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QIntValidator, QDoubleValidator
|
||||
from PySide6.QtCore import QEvent, QSize, Qt
|
||||
|
||||
from src.shared.shareddata import APP_NAME
|
||||
from PySide6.QtCore import QEvent, QPoint, QSize, QTimer, Qt, Signal
|
||||
|
||||
from src.shared.shareddata import APP_NAME, PIPELINE_STAGES
|
||||
|
||||
class FullClickComboBox(QComboBox):
|
||||
def __init__(self, parent=None):
|
||||
@@ -51,7 +50,7 @@ class ClickableLabel(QLabel):
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
viewer = QWidget()
|
||||
viewer.setWindowTitle("Expanded View")
|
||||
viewer.setWindowTitle(f"Expanded View" - {APP_NAME.upper()})
|
||||
layout = QVBoxLayout(viewer)
|
||||
label = QLabel()
|
||||
label.setPixmap(self._pixmap_full)
|
||||
@@ -85,7 +84,7 @@ class ParameterInputDialog(QDialog):
|
||||
}
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Input Parameters")
|
||||
self.setWindowTitle(f"Input Parameters - {APP_NAME.upper()}")
|
||||
self.params_dict = params_dict
|
||||
self.inputs = {} # {(idx, param_key): QLineEdit}
|
||||
|
||||
@@ -218,7 +217,7 @@ class ParameterInputDialog(QDialog):
|
||||
def _show_error(self, message):
|
||||
error_box = QMessageBox(self)
|
||||
error_box.setIcon(QMessageBox.Critical)
|
||||
error_box.setWindowTitle("Input Error")
|
||||
error_box.setWindowTitle(f"Input Error - {APP_NAME.upper()}")
|
||||
error_box.setText(message)
|
||||
error_box.exec_()
|
||||
|
||||
@@ -244,9 +243,6 @@ class FullClickComboBox(QComboBox):
|
||||
|
||||
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton, QFileDialog
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
class FilePickerWidget(QWidget):
|
||||
# This custom signal lets our container mimic a standard QLineEdit
|
||||
textChanged = Signal(str)
|
||||
@@ -294,9 +290,7 @@ class FilePickerWidget(QWidget):
|
||||
|
||||
class ParamSection(QWidget):
|
||||
"""
|
||||
A widget section that dynamically creates labeled input fields from parameter metadata.
|
||||
|
||||
Args:
|
||||
Args:
|
||||
section_data (dict): Dictionary containing section title and list of parameter info.
|
||||
Expected format:
|
||||
{
|
||||
@@ -312,6 +306,8 @@ class ParamSection(QWidget):
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
dirty_state_changed = Signal(bool)
|
||||
|
||||
def __init__(self, section_data, global_widgets):
|
||||
super().__init__()
|
||||
@@ -323,6 +319,8 @@ class ParamSection(QWidget):
|
||||
|
||||
self.param_rows = []
|
||||
self.header_widgets = []
|
||||
self.dirty_params = {}
|
||||
self._updating_checkstates = False
|
||||
|
||||
# Title label
|
||||
title_label = QLabel(section_data["title"])
|
||||
@@ -339,30 +337,23 @@ class ParamSection(QWidget):
|
||||
|
||||
for param in section_data["params"]:
|
||||
h_layout = QHBoxLayout()
|
||||
|
||||
is_advanced = param.get("advanced", False)
|
||||
param_name = param["name"]
|
||||
help_text = param.get("help", "")
|
||||
|
||||
# Build label text and tooltips
|
||||
param_name = param["name"]
|
||||
if is_advanced:
|
||||
label_text = f"⚠️ {param_name}"
|
||||
else:
|
||||
label_text = param_name
|
||||
|
||||
label_text = f"⚠️ {param_name}" if is_advanced else param_name
|
||||
label = QLabel(label_text)
|
||||
|
||||
# Set hover tooltip
|
||||
|
||||
if is_advanced:
|
||||
label.setToolTip(f"ADVANCED: {param.get("help", "")}")
|
||||
label.setToolTip(f"ADVANCED: {help_text}")
|
||||
else:
|
||||
label.setToolTip(param.get("help", ""))
|
||||
|
||||
help_text = param.get("help", "")
|
||||
label.setToolTip(help_text)
|
||||
|
||||
help_btn = QPushButton("?")
|
||||
help_btn.setFixedWidth(25)
|
||||
help_btn.setToolTip(help_text)
|
||||
help_btn.clicked.connect(lambda _, text=help_text: self.show_help_popup(text))
|
||||
help_btn.clicked.connect(lambda _, text=help_text, name=param_name: self.show_help_popup(name, text))
|
||||
|
||||
h_layout.addWidget(help_btn)
|
||||
h_layout.addWidget(label)
|
||||
@@ -370,220 +361,331 @@ class ParamSection(QWidget):
|
||||
h_layout.setStretch(1, 6)
|
||||
|
||||
default_val = param["default"]
|
||||
param_type = param["type"]
|
||||
type_str = str(param_type).lower()
|
||||
|
||||
# Create input widget based on type
|
||||
if param["type"] == bool:
|
||||
if param_type == bool or "bool" in type_str:
|
||||
widget = QComboBox()
|
||||
widget.addItems(["True", "False"])
|
||||
widget.setCurrentText(str(default_val))
|
||||
widget.currentTextChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
|
||||
widget.currentTextChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||
widget.currentTextChanged.connect(self.notify_global_update)
|
||||
elif param["type"] == int:
|
||||
|
||||
elif param_type == int or "int" in type_str:
|
||||
widget = QLineEdit()
|
||||
widget.setValidator(QIntValidator())
|
||||
widget.setText(str(default_val))
|
||||
widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
|
||||
elif param["type"] == float:
|
||||
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||
|
||||
elif param_type == float or "float" in type_str:
|
||||
widget = QLineEdit()
|
||||
widget.setValidator(QDoubleValidator())
|
||||
widget.setText(str(default_val))
|
||||
widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
|
||||
elif param["type"] == list:
|
||||
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||
|
||||
elif param_type == list or "list" in type_str:
|
||||
options = param.get("options", [])
|
||||
if param.get("exclusive", True):
|
||||
widget = QComboBox()
|
||||
widget.addItems(param.get("options", []))
|
||||
widget.setCurrentText(str(default_val))
|
||||
widget.currentTextChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
|
||||
widget.addItems(options)
|
||||
initial_text = default_val[0] if (isinstance(default_val, list) and len(default_val) > 0) else str(default_val)
|
||||
widget.setCurrentText(initial_text)
|
||||
widget.currentTextChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||
widget.currentTextChanged.connect(self.notify_global_update)
|
||||
else:
|
||||
widget = self._create_multiselect_dropdown(None)
|
||||
elif param["type"] == range:
|
||||
widget = self._create_multiselect_dropdown(options, default_val=default_val, param_name=param_name)
|
||||
|
||||
elif param_type == range or "range" in type_str:
|
||||
widget = QSpinBox()
|
||||
widget.setRange(0, 999) #NOTE: will this be a high enough limit?
|
||||
# If default is "None" or range(15), handle it gracefully:
|
||||
widget.setRange(0, 999)
|
||||
if isinstance(default_val, range):
|
||||
widget.setValue(default_val.stop)
|
||||
elif str(default_val).isdigit():
|
||||
widget.setValue(int(default_val))
|
||||
else:
|
||||
widget.setValue(15) # Default fallback
|
||||
widget.valueChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
|
||||
elif param["type"] == "json_file":
|
||||
# Create our custom dual-element compound layout widget
|
||||
widget.setValue(15)
|
||||
widget.valueChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||
|
||||
elif param_type == "json_file":
|
||||
widget = FilePickerWidget(default_val=default_val, file_filter="JSON Files (*.json)")
|
||||
# Your existing tracking signals connect seamlessly!
|
||||
widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
|
||||
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||
|
||||
else:
|
||||
widget = QLineEdit()
|
||||
widget.setText(str(default_val))
|
||||
widget.textChanged.connect(lambda val, p=param["name"]: self.check_if_changed(p, val))
|
||||
widget.textChanged.connect(lambda _, p=param_name: self.check_if_changed(p))
|
||||
|
||||
self.widgets[param_name] = {
|
||||
"widget": widget,
|
||||
"label": label,
|
||||
"default": default_val,
|
||||
"saved_value": deepcopy(default_val),
|
||||
"type": param_type,
|
||||
"h_layout": h_layout
|
||||
}
|
||||
|
||||
if "depends_on" in param:
|
||||
deps_list = []
|
||||
|
||||
if isinstance(param["depends_on"], list):
|
||||
deps_list = param["depends_on"]
|
||||
|
||||
else:
|
||||
deps_list.append({
|
||||
"parent_name": param["depends_on"],
|
||||
"depends_value": param.get("depends_value", "True")
|
||||
})
|
||||
|
||||
deps_list = param["depends_on"] if isinstance(param["depends_on"], list) else [{
|
||||
"parent_name": param["depends_on"],
|
||||
"depends_value": param.get("depends_value", "True")
|
||||
}]
|
||||
self.dependencies.append({
|
||||
"child_name": param["name"],
|
||||
"child_name": param_name,
|
||||
"conditions": deps_list
|
||||
})
|
||||
|
||||
widget.setToolTip(help_text)
|
||||
|
||||
h_layout.addWidget(widget)
|
||||
h_layout.setStretch(2, 3)
|
||||
|
||||
layout.addLayout(h_layout)
|
||||
self.widgets[param["name"]] = {
|
||||
"widget": widget,
|
||||
"label": label,
|
||||
"default": default_val,
|
||||
"type": param["type"],
|
||||
"h_layout": h_layout
|
||||
}
|
||||
|
||||
row_widgets = [help_btn, label, widget]
|
||||
is_advanced = param.get("advanced", False)
|
||||
self.param_rows.append((row_widgets, h_layout, is_advanced))
|
||||
self.param_rows.append(([help_btn, label, widget], h_layout, is_advanced))
|
||||
|
||||
self.update_dependencies()
|
||||
|
||||
def set_advanced_visible(self, show_advanced: bool):
|
||||
"""Shows/hides advanced parameters and adjusts header visibility dynamically."""
|
||||
has_visible_rows = False
|
||||
def is_different(self, val_a, val_b, param_type=None):
|
||||
"""Compares two parameter values to determine if they differ."""
|
||||
type_str = str(param_type).lower()
|
||||
|
||||
for row_widgets, h_layout, is_advanced in self.param_rows:
|
||||
visible = show_advanced or not is_advanced
|
||||
|
||||
# Toggle visibility of all child widgets in the row
|
||||
for w in row_widgets:
|
||||
w.setVisible(visible)
|
||||
|
||||
if visible:
|
||||
has_visible_rows = True
|
||||
# BOOL comparison
|
||||
if param_type == bool or "bool" in type_str:
|
||||
def to_bool(v):
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
return str(v).strip().lower() in ("true", "1", "yes")
|
||||
return to_bool(val_a) != to_bool(val_b)
|
||||
|
||||
# Hide or show section header title and horizontal rule if section is completely empty
|
||||
for hw in self.header_widgets:
|
||||
hw.setVisible(has_visible_rows)
|
||||
|
||||
self.setVisible(has_visible_rows)
|
||||
# LIST comparison
|
||||
if param_type == list or "list" in type_str:
|
||||
def to_list(v):
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
res = []
|
||||
for item in v:
|
||||
res.extend(to_list(item))
|
||||
return res
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if s.startswith('[') and s.endswith(']'):
|
||||
s = s[1:-1]
|
||||
items = [x.strip().strip("'\"") for x in s.split(',') if x.strip().strip("'\"")]
|
||||
return [i for i in items if i != "<None Selected>"]
|
||||
return [str(v).strip()]
|
||||
|
||||
return sorted(to_list(val_a)) != sorted(to_list(val_b))
|
||||
|
||||
def has_any_changes(self):
|
||||
"""Returns True if any parameter in this section differs from its default."""
|
||||
for name, info in self.widgets.items():
|
||||
default = info["default"]
|
||||
|
||||
current_val = self.get_param_values().get(name)
|
||||
|
||||
if str(current_val) != str(default):
|
||||
return True
|
||||
return False
|
||||
|
||||
# RANGE comparison
|
||||
if param_type == range or "range" in type_str:
|
||||
def to_range_stop(v):
|
||||
if isinstance(v, range):
|
||||
return v.stop
|
||||
try:
|
||||
return int(v)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
return to_range_stop(val_a) != to_range_stop(val_b)
|
||||
|
||||
def check_if_changed(self, param_name, current_value):
|
||||
"""Toggles bold font on the label if the value differs from default."""
|
||||
# INT / FLOAT comparison
|
||||
if param_type in (int, float) or "int" in type_str or "float" in type_str:
|
||||
try:
|
||||
if val_a is not None and val_b is not None and str(val_a).strip() != "" and str(val_b).strip() != "":
|
||||
return float(val_a) != float(val_b)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# String / Default Fallback
|
||||
str_a = "" if val_a is None else str(val_a).strip()
|
||||
str_b = "" if val_b is None else str(val_b).strip()
|
||||
return str_a != str_b
|
||||
|
||||
def check_if_changed(self, param_name, current_value=None, *args, **kwargs):
|
||||
"""
|
||||
Toggles bold font/blue color on the label if the value differs from default.
|
||||
Always pulls current UI value from get_param_values() if not explicitly passed.
|
||||
"""
|
||||
info = self.widgets.get(param_name)
|
||||
if not info:
|
||||
return
|
||||
|
||||
label = info["label"]
|
||||
default = info["default"]
|
||||
|
||||
is_changed = False
|
||||
|
||||
if info["type"] == list:
|
||||
# If it's an exclusive ComboBox, current_value is a string.
|
||||
# We wrap it in a list to compare it to the default list.
|
||||
if isinstance(current_value, str):
|
||||
normalized_current = [current_value]
|
||||
else:
|
||||
normalized_current = current_value # Already a list from multi-select
|
||||
|
||||
# Ensure default is a list for comparison
|
||||
normalized_default = default if isinstance(default, list) else [default]
|
||||
|
||||
# Use sorted to ensure order doesn't matter
|
||||
is_changed = sorted(normalized_current) != sorted(normalized_default)
|
||||
saved = info.get("saved_value", default)
|
||||
param_type = info["type"]
|
||||
|
||||
# 2. Handle Range (SpinBox)
|
||||
elif info["type"] == range:
|
||||
ref = default.stop if isinstance(default, range) else default
|
||||
try:
|
||||
is_changed = int(current_value) != int(ref)
|
||||
except (ValueError, TypeError):
|
||||
is_changed = True
|
||||
if current_value is None:
|
||||
current_value = self.get_param_values().get(param_name)
|
||||
|
||||
# 3. Standard Comparison (bool, int, float, str)
|
||||
else:
|
||||
is_changed = str(current_value) != str(default)
|
||||
|
||||
if is_changed:
|
||||
label.setStyleSheet("color: #3498db; font-weight: bold;") # Nice Blue
|
||||
# 1. COLORING LOGIC (Compares against 'default')
|
||||
if self.is_different(current_value, default, param_type):
|
||||
label.setStyleSheet("color: #3498db; font-weight: bold;")
|
||||
else:
|
||||
label.setStyleSheet("")
|
||||
|
||||
# 2. DIRTY STATE LOGIC (Compares against 'saved_value')
|
||||
self.dirty_params[param_name] = self.is_different(current_value, saved, param_type)
|
||||
self.dirty_state_changed.emit(any(self.dirty_params.values()))
|
||||
|
||||
def reset_to_defaults(self):
|
||||
"""
|
||||
Resets all UI input widgets back to their defined default values in SECTIONS,
|
||||
resets saved baseline states, updates label styling (removes blue highlight),
|
||||
and updates dependent widget visibility/enablement.
|
||||
"""
|
||||
for param_name, info in self.widgets.items():
|
||||
widget = info["widget"]
|
||||
default_val = info["default"]
|
||||
param_type = info["type"]
|
||||
type_str = str(param_type).lower()
|
||||
|
||||
# Update saved baseline to match default
|
||||
info["saved_value"] = deepcopy(default_val)
|
||||
|
||||
# Reset Widget Values
|
||||
if param_type == bool or "bool" in type_str:
|
||||
if isinstance(widget, QComboBox):
|
||||
widget.setCurrentText(str(default_val))
|
||||
|
||||
elif param_type in (int, float, str) or "int" in type_str or "float" in type_str or "str" in type_str:
|
||||
if isinstance(widget, QLineEdit):
|
||||
widget.setText("" if default_val is None else str(default_val))
|
||||
|
||||
elif param_type == list or "list" in type_str:
|
||||
if isinstance(widget, FullClickComboBox):
|
||||
defaults = default_val if isinstance(default_val, list) else ([default_val] if default_val else [])
|
||||
model = widget.model()
|
||||
self._updating_checkstates = True
|
||||
normal_items = []
|
||||
for i in range(2, model.rowCount()):
|
||||
item = model.item(i)
|
||||
normal_items.append(item)
|
||||
state = Qt.Checked if item.text() in defaults else Qt.Unchecked
|
||||
item.setCheckState(state)
|
||||
|
||||
# Sync toggle select all item
|
||||
toggle_item = model.item(1)
|
||||
if toggle_item and normal_items:
|
||||
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
|
||||
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
|
||||
|
||||
self._updating_checkstates = False
|
||||
self.update_dropdown_label(param_name)
|
||||
elif isinstance(widget, QComboBox):
|
||||
initial_text = default_val[0] if (isinstance(default_val, list) and len(default_val) > 0) else str(default_val)
|
||||
widget.setCurrentText(initial_text)
|
||||
|
||||
elif param_type == range or "range" in type_str:
|
||||
if isinstance(widget, QSpinBox):
|
||||
if isinstance(default_val, range):
|
||||
widget.setValue(default_val.stop)
|
||||
elif str(default_val).isdigit():
|
||||
widget.setValue(int(default_val))
|
||||
else:
|
||||
widget.setValue(15)
|
||||
|
||||
elif param_type == "json_file":
|
||||
if hasattr(widget, "setText"):
|
||||
widget.setText("" if default_val is None else str(default_val))
|
||||
|
||||
# Re-evaluate visual styling and dirty flags
|
||||
self.check_if_changed(param_name)
|
||||
|
||||
self.dirty_params.clear()
|
||||
self.dirty_state_changed.emit(False)
|
||||
self.update_dependencies()
|
||||
|
||||
# Aliases for clear button calls
|
||||
def clear(self):
|
||||
self.reset_to_defaults()
|
||||
|
||||
def reset(self):
|
||||
self.reset_to_defaults()
|
||||
|
||||
def reset_baseline_to_default(self):
|
||||
"""Resets baseline saved values back to defaults and re-checks visual styling."""
|
||||
for name, info in self.widgets.items():
|
||||
info["saved_value"] = deepcopy(info["default"])
|
||||
self.check_if_changed(name)
|
||||
|
||||
self.dirty_params.clear()
|
||||
self.dirty_state_changed.emit(False)
|
||||
|
||||
def save_current_as_baseline(self):
|
||||
"""Call this when a project is saved to lock current UI state as saved_value."""
|
||||
current_values = self.get_param_values()
|
||||
for name, info in self.widgets.items():
|
||||
if name in current_values:
|
||||
info["saved_value"] = deepcopy(current_values[name])
|
||||
self.check_if_changed(name)
|
||||
|
||||
self.dirty_params.clear()
|
||||
self.dirty_state_changed.emit(False)
|
||||
|
||||
def set_advanced_visible(self, show_advanced: bool):
|
||||
has_visible_rows = False
|
||||
for row_widgets, _, is_advanced in self.param_rows:
|
||||
visible = show_advanced or not is_advanced
|
||||
for w in row_widgets:
|
||||
w.setVisible(visible)
|
||||
if visible:
|
||||
has_visible_rows = True
|
||||
|
||||
for hw in self.header_widgets:
|
||||
hw.setVisible(has_visible_rows)
|
||||
|
||||
self.setVisible(has_visible_rows)
|
||||
|
||||
def has_any_changes(self):
|
||||
current_values = self.get_param_values()
|
||||
for name, info in self.widgets.items():
|
||||
if self.is_different(current_values.get(name), info["default"], info["type"]):
|
||||
return True
|
||||
return False
|
||||
|
||||
def notify_global_update(self):
|
||||
"""
|
||||
Since dependencies can cross sections, we need to tell
|
||||
all sections to refresh their enabled/disabled states.
|
||||
"""
|
||||
# If you have a reference to the parent container, call its update.
|
||||
# Otherwise, you can iterate through the known param_sections:
|
||||
for section in self.parent().findChildren(ParamSection):
|
||||
section.update_dependencies()
|
||||
parent = self.parent()
|
||||
if parent:
|
||||
for section in parent.findChildren(ParamSection):
|
||||
section.update_dependencies()
|
||||
|
||||
def update_dependencies(self):
|
||||
"""Disables/Enables widgets based on parent selection values."""
|
||||
for dep in self.dependencies:
|
||||
child_info = self.widgets.get(dep["child_name"])
|
||||
if not child_info:
|
||||
continue
|
||||
|
||||
# Default to enabled until a condition fails
|
||||
all_conditions_met = True
|
||||
|
||||
for cond in dep["conditions"]:
|
||||
parent_name = cond.get("parent_name") or cond.get("parent")
|
||||
required_value = str(cond.get("depends_value") if "depends_value" in cond else cond.get("value", "True"))
|
||||
required_val = str(cond.get("depends_value") if "depends_value" in cond else cond.get("value", "True"))
|
||||
|
||||
parent_info = self.widgets.get(parent_name)
|
||||
if not parent_info:
|
||||
all_conditions_met = False
|
||||
break
|
||||
|
||||
parent_widget = parent_info["widget"]
|
||||
|
||||
# Extract current parent value based on widget type
|
||||
if isinstance(parent_widget, QComboBox):
|
||||
current_parent_value = parent_widget.currentText()
|
||||
elif isinstance(parent_widget, QLineEdit):
|
||||
current_parent_value = parent_widget.text()
|
||||
elif isinstance(parent_widget, QSpinBox):
|
||||
current_parent_value = str(parent_widget.value())
|
||||
p_widget = parent_info["widget"]
|
||||
if isinstance(p_widget, QComboBox):
|
||||
curr_val = p_widget.currentText()
|
||||
elif isinstance(p_widget, QLineEdit):
|
||||
curr_val = p_widget.text()
|
||||
elif isinstance(p_widget, QSpinBox):
|
||||
curr_val = str(p_widget.value())
|
||||
else:
|
||||
current_parent_value = str(parent_widget)
|
||||
curr_val = str(p_widget)
|
||||
|
||||
# If any condition fails, flag as false
|
||||
if current_parent_value != required_value:
|
||||
if curr_val != required_val:
|
||||
all_conditions_met = False
|
||||
break
|
||||
|
||||
# Toggle the entire row (Button, Label, and Input)
|
||||
|
||||
h_layout = child_info["h_layout"]
|
||||
for i in range(h_layout.count()):
|
||||
item = h_layout.itemAt(i).widget()
|
||||
if item:
|
||||
item.setEnabled(all_conditions_met)
|
||||
|
||||
def _create_multiselect_dropdown(self, items):
|
||||
def _create_multiselect_dropdown(self, items, default_val=None, param_name=""):
|
||||
combo = FullClickComboBox()
|
||||
combo.setView(QListView())
|
||||
model = QStandardItemModel()
|
||||
@@ -601,16 +703,18 @@ class ParamSection(QWidget):
|
||||
toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole)
|
||||
model.appendRow(toggle_item)
|
||||
|
||||
if items is not None:
|
||||
for item in items:
|
||||
standard_item = QStandardItem(item)
|
||||
defaults = default_val if isinstance(default_val, list) else ([default_val] if default_val else [])
|
||||
|
||||
if items:
|
||||
for item_text in items:
|
||||
standard_item = QStandardItem(item_text)
|
||||
standard_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
||||
standard_item.setData(Qt.Unchecked, Qt.CheckStateRole)
|
||||
initial_state = Qt.Checked if item_text in defaults else Qt.Unchecked
|
||||
standard_item.setData(initial_state, Qt.CheckStateRole)
|
||||
model.appendRow(standard_item)
|
||||
|
||||
combo.setInsertPolicy(QComboBox.NoInsert)
|
||||
|
||||
|
||||
def on_view_clicked(index):
|
||||
item = model.itemFromIndex(index)
|
||||
if item.isCheckable():
|
||||
@@ -619,171 +723,70 @@ class ParamSection(QWidget):
|
||||
|
||||
combo.view().pressed.connect(on_view_clicked)
|
||||
|
||||
self._updating_checkstates = False
|
||||
|
||||
def on_item_changed(item):
|
||||
if self._updating_checkstates:
|
||||
return
|
||||
self._updating_checkstates = True
|
||||
|
||||
normal_items = [model.item(i) for i in range(2, model.rowCount())] # skip dummy and toggle
|
||||
normal_items = [model.item(i) for i in range(2, model.rowCount())]
|
||||
|
||||
if item == toggle_item:
|
||||
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
|
||||
if all_checked:
|
||||
for i in normal_items:
|
||||
i.setCheckState(Qt.Unchecked)
|
||||
toggle_item.setCheckState(Qt.Unchecked)
|
||||
else:
|
||||
for i in normal_items:
|
||||
i.setCheckState(Qt.Checked)
|
||||
toggle_item.setCheckState(Qt.Checked)
|
||||
|
||||
elif item == dummy_item:
|
||||
pass
|
||||
|
||||
else:
|
||||
# When normal items change, update toggle item
|
||||
target_state = Qt.Unchecked if all_checked else Qt.Checked
|
||||
for i in normal_items:
|
||||
i.setCheckState(target_state)
|
||||
toggle_item.setCheckState(target_state)
|
||||
elif item != dummy_item:
|
||||
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
|
||||
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
|
||||
|
||||
|
||||
self._updating_checkstates = False
|
||||
|
||||
for param_name, info in self.widgets.items():
|
||||
if info["widget"] == combo:
|
||||
self.update_dropdown_label(param_name)
|
||||
break
|
||||
if param_name:
|
||||
self.update_dropdown_label(param_name)
|
||||
self.check_if_changed(param_name)
|
||||
|
||||
model.itemChanged.connect(on_item_changed)
|
||||
|
||||
combo.setInsertPolicy(QComboBox.NoInsert)
|
||||
return combo
|
||||
|
||||
def show_help_popup(self, text):
|
||||
def show_help_popup(self, param_name, text):
|
||||
msg = QMessageBox(self)
|
||||
msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}")
|
||||
msg.setWindowTitle(f"Parameter Info - {param_name} - {APP_NAME.upper()}")
|
||||
msg.setText(text)
|
||||
msg.exec()
|
||||
|
||||
|
||||
def get_param_values(self):
|
||||
values = {}
|
||||
for name, info in self.widgets.items():
|
||||
widget = info["widget"]
|
||||
expected_type = info["type"]
|
||||
type_str = str(expected_type).lower()
|
||||
|
||||
if name == "SHORT_CHANNEL_REGRESSION":
|
||||
# If the widget is disabled (greyed out), force False
|
||||
if not widget.isEnabled():
|
||||
values[name] = False
|
||||
continue
|
||||
|
||||
if expected_type == bool:
|
||||
if expected_type == bool or "bool" in type_str:
|
||||
values[name] = widget.currentText() == "True"
|
||||
elif expected_type == list:
|
||||
elif expected_type == list or "list" in type_str:
|
||||
if isinstance(widget, FullClickComboBox):
|
||||
values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip()]
|
||||
values[name] = [x.strip() for x in widget.lineEdit().text().split(",") if x.strip() and x.strip() != "<None Selected>"]
|
||||
elif isinstance(widget, QComboBox):
|
||||
values[name] = widget.currentText()
|
||||
elif expected_type == range:
|
||||
if isinstance(widget, QSpinBox):
|
||||
# Convert the integer N into range(N)
|
||||
values[name] = range(widget.value())
|
||||
else:
|
||||
values[name] = range(15) # Fallback
|
||||
elif expected_type == range or "range" in type_str:
|
||||
values[name] = range(widget.value()) if isinstance(widget, QSpinBox) else range(15)
|
||||
elif expected_type == "json_file":
|
||||
values[name] = widget.text()
|
||||
else:
|
||||
raw_text = widget.text()
|
||||
try:
|
||||
if expected_type == int:
|
||||
if expected_type == int or "int" in type_str:
|
||||
values[name] = int(raw_text)
|
||||
elif expected_type == float:
|
||||
elif expected_type == float or "float" in type_str:
|
||||
values[name] = float(raw_text)
|
||||
elif expected_type == str:
|
||||
values[name] = raw_text
|
||||
else:
|
||||
values[name] = raw_text # Fallback
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid value for {name}: {raw_text}") from e
|
||||
values[name] = raw_text
|
||||
except Exception:
|
||||
values[name] = raw_text
|
||||
|
||||
return values
|
||||
|
||||
def update_dropdown_items(self, param_name, new_items):
|
||||
"""
|
||||
Updates the items in a multi-select dropdown parameter field.
|
||||
|
||||
Args:
|
||||
param_name (str): The parameter name (must match one in self.widgets).
|
||||
new_items (list): The new items to populate in the dropdown.
|
||||
"""
|
||||
widget_info = self.widgets.get(param_name)
|
||||
#print("[ParamSection] Current widget keys:", list(self.widgets.keys()))
|
||||
|
||||
if not widget_info:
|
||||
print(f"[ParamSection] No widget found for param '{param_name}'")
|
||||
return
|
||||
|
||||
widget = widget_info["widget"]
|
||||
if not isinstance(widget, FullClickComboBox):
|
||||
print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox")
|
||||
return
|
||||
|
||||
# Replace the model on the existing widget
|
||||
new_model = QStandardItemModel()
|
||||
|
||||
dummy_item = QStandardItem("<None Selected>")
|
||||
dummy_item.setFlags(Qt.ItemIsEnabled)
|
||||
new_model.appendRow(dummy_item)
|
||||
|
||||
toggle_item = QStandardItem("Toggle Select All")
|
||||
toggle_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
||||
toggle_item.setData(Qt.Unchecked, Qt.CheckStateRole)
|
||||
new_model.appendRow(toggle_item)
|
||||
|
||||
for item_text in new_items:
|
||||
item = QStandardItem(item_text)
|
||||
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
||||
item.setData(Qt.Unchecked, Qt.CheckStateRole)
|
||||
new_model.appendRow(item)
|
||||
|
||||
widget.setModel(new_model)
|
||||
widget.setView(QListView()) # Reset view to refresh properly
|
||||
|
||||
def on_view_clicked(index):
|
||||
item = new_model.itemFromIndex(index)
|
||||
if item.isCheckable():
|
||||
new_state = Qt.Checked if item.checkState() == Qt.Unchecked else Qt.Unchecked
|
||||
item.setCheckState(new_state)
|
||||
|
||||
widget.view().pressed.connect(on_view_clicked)
|
||||
|
||||
def on_item_changed(item):
|
||||
if getattr(self, "_updating_checkstates", False):
|
||||
return
|
||||
self._updating_checkstates = True
|
||||
|
||||
normal_items = [new_model.item(i) for i in range(2, new_model.rowCount())]
|
||||
if item == toggle_item:
|
||||
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
|
||||
for i in normal_items:
|
||||
i.setCheckState(Qt.Unchecked if all_checked else Qt.Checked)
|
||||
toggle_item.setCheckState(Qt.Unchecked if all_checked else Qt.Checked)
|
||||
else:
|
||||
all_checked = all(i.checkState() == Qt.Checked for i in normal_items)
|
||||
toggle_item.setCheckState(Qt.Checked if all_checked else Qt.Unchecked)
|
||||
|
||||
self._updating_checkstates = False
|
||||
|
||||
for param_name, info in self.widgets.items():
|
||||
if info["widget"] == widget:
|
||||
self.update_dropdown_label(param_name)
|
||||
break
|
||||
|
||||
new_model.itemChanged.connect(on_item_changed)
|
||||
widget.lineEdit().setText("<None Selected>")
|
||||
|
||||
|
||||
def _get_checked_items(self, combo):
|
||||
checked = []
|
||||
model = combo.model()
|
||||
@@ -798,20 +801,14 @@ class ParamSection(QWidget):
|
||||
def update_dropdown_label(self, param_name):
|
||||
widget_info = self.widgets.get(param_name)
|
||||
if not widget_info:
|
||||
print(f"[ParamSection] No widget found for param '{param_name}'")
|
||||
return
|
||||
|
||||
widget = widget_info["widget"]
|
||||
if not isinstance(widget, FullClickComboBox):
|
||||
print(f"[ParamSection] Widget for param '{param_name}' is not a FullClickComboBox")
|
||||
return
|
||||
|
||||
selected = self._get_checked_items(widget)
|
||||
if not selected:
|
||||
widget.lineEdit().setText("<None Selected>")
|
||||
else:
|
||||
# You can customize how you display selected items here:
|
||||
widget.lineEdit().setText(", ".join(selected))
|
||||
widget.lineEdit().setText(", ".join(selected) if selected else "<None Selected>")
|
||||
|
||||
# def update_annotation_dropdown_from_loaded_files(self, bubble_widgets, button1):
|
||||
# file_paths = [bubble.file_path for bubble in bubble_widgets.values()]
|
||||
@@ -860,6 +857,135 @@ class ParamSection(QWidget):
|
||||
# self.update_dropdown_items("REMOVE_EVENTS", sorted(list(common)))
|
||||
|
||||
|
||||
class ProgressBubble(QWidget):
|
||||
"""
|
||||
A clickable widget displaying a progress bar made of colored rectangles and a label.
|
||||
|
||||
Args:
|
||||
display_name (str): Text to display above the progress bar.
|
||||
file_path (str): Associated file path stored with the bubble.
|
||||
|
||||
"""
|
||||
|
||||
clicked = Signal(object)
|
||||
rightClicked = Signal(object, QPoint)
|
||||
|
||||
def __init__(self, display_name, file_path):
|
||||
super().__init__()
|
||||
|
||||
self.layout = QVBoxLayout()
|
||||
self.label = QLabel(display_name)
|
||||
self.loading_timer = QTimer(self)
|
||||
self.loading_timer.timeout.connect(self._rotate_spinner)
|
||||
self.spinner_frames = ["◐", "◓", "◑", "◒"] #cute
|
||||
self.spinner_idx = 0
|
||||
self.is_loading = False
|
||||
self.base_text = display_name
|
||||
self.status_icon = ""
|
||||
self.suffix_text = ""
|
||||
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.label.setStyleSheet("""
|
||||
QLabel {
|
||||
border: 1px solid #888;
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
background-color: #e0f0ff;
|
||||
}
|
||||
""")
|
||||
|
||||
self.progress_layout = QHBoxLayout()
|
||||
|
||||
self.rects = []
|
||||
for i in range(28):
|
||||
rect = QFrame()
|
||||
rect.setFixedSize(10, 18)
|
||||
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
|
||||
stage_name = PIPELINE_STAGES[i]
|
||||
rect.setToolTip(f"Stage {i + 1}: {stage_name}")
|
||||
self.progress_layout.addWidget(rect)
|
||||
self.rects.append(rect)
|
||||
|
||||
self.layout.addWidget(self.label)
|
||||
self.layout.addLayout(self.progress_layout)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
# Store the file path
|
||||
self.file_path = os.path.normpath(file_path)
|
||||
|
||||
self.current_step = 0
|
||||
|
||||
# Make the bubble appear to the user as clickable
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
# Resize policy to make bubbles responsive
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
|
||||
|
||||
def _update_label_text(self):
|
||||
"""Combines base text, green checkmark/spinner, and metadata into one display label."""
|
||||
text = self.base_text
|
||||
if self.status_icon:
|
||||
text += f" {self.status_icon}"
|
||||
if self.suffix_text:
|
||||
text += f" ({self.suffix_text})"
|
||||
self.label.setText(text)
|
||||
|
||||
|
||||
def set_loading_state(self, loading=True):
|
||||
self.is_loading = loading
|
||||
if loading:
|
||||
self.loading_timer.start(150) # Rotate every 150ms
|
||||
else:
|
||||
self.loading_timer.stop()
|
||||
# Transition to a green checkmark
|
||||
self.status_icon = "<span style='color: green;'>✔</span>"
|
||||
self._update_label_text()
|
||||
|
||||
|
||||
def setSuffixText(self, suffix):
|
||||
"""Updates the metadata text portion without destroying the checkmark."""
|
||||
self.suffix_text = suffix if suffix else ""
|
||||
self._update_label_text()
|
||||
|
||||
|
||||
def update_progress(self, step_index, active=True):
|
||||
self.current_step = step_index
|
||||
for i, rect in enumerate(self.rects):
|
||||
if i < step_index:
|
||||
rect.setStyleSheet("background-color: green; border: 1px solid gray;")
|
||||
elif i == step_index:
|
||||
color = "yellow" if active else "white"
|
||||
rect.setStyleSheet(f"background-color: {color}; border: 1px solid gray;")
|
||||
else:
|
||||
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
|
||||
|
||||
|
||||
def mark_cancelled(self):
|
||||
for i, rect in enumerate(self.rects):
|
||||
if i < self.current_step:
|
||||
rect.setStyleSheet("background-color: green; border: 1px solid gray;")
|
||||
elif i == self.current_step:
|
||||
rect.setStyleSheet("background-color: red; border: 1px solid gray;")
|
||||
else:
|
||||
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
|
||||
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.clicked.emit(self)
|
||||
elif event.button() == Qt.MouseButton.RightButton:
|
||||
self.rightClicked.emit(self, event.globalPosition().toPoint())
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
def _rotate_spinner(self):
|
||||
frame = self.spinner_frames[self.spinner_idx % len(self.spinner_frames)]
|
||||
# Using HTML in setText allows us to style the spinner specifically
|
||||
self.status_icon = f"<span style='color: #555;'>{frame}</span>"
|
||||
self.spinner_idx += 1
|
||||
self._update_label_text()
|
||||
|
||||
|
||||
class FlaresBaseWidget(QWidget):
|
||||
def __init__(self, caller):
|
||||
super().__init__()
|
||||
|
||||
@@ -13,7 +13,7 @@ import sys
|
||||
import platform
|
||||
|
||||
|
||||
CURRENT_VERSION = "1.5.2"
|
||||
CURRENT_VERSION = "1.6.0"
|
||||
APP_NAME = "flares"
|
||||
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
|
||||
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
|
||||
@@ -55,6 +55,20 @@ PIPELINE_STAGES = [
|
||||
]
|
||||
|
||||
|
||||
DATA_SCHEMA = [
|
||||
{"key": "raw_haemo_dict", "help": "Dict[file_path, MNE RawArray]: Haemodynamic raw data"},
|
||||
{"key": "epochs_dict", "help": "Dict[file_path, MNE Epochs]: Time-locked epoch data"},
|
||||
{"key": "cha_dict", "help": "Dict[file_path, DataFrame]: Channel analysis results"},
|
||||
{"key": "df_ind_dict", "help": "Dict[file_path, DataFrame]: Individual-level data/ROI results"},
|
||||
{"key": "design_matrix_dict", "help": "Dict[file_path, DataFrame]: GLM design matrices"},
|
||||
{"key": "config_dict", "help": "Dict[file_path, dict]: Processing configuration parameters"},
|
||||
{"key": "fig_bytes_dict", "help": "Dict[file_path, dict]: Serialized figure data"},
|
||||
{"key": "contrast_results_dict", "help": "Dict[file_path, dict]: Calculated contrast statistical results"},
|
||||
{"key": "roi_channel_map_dict", "help": "Dict[file_path, dict]: Calculated contrast statistical results"},
|
||||
{"key": "valid_dict", "help": "Dict[file_path, bool]: Boolean validity status per file"}
|
||||
]
|
||||
|
||||
|
||||
def resource_path(relative_path: str) -> str:
|
||||
"""
|
||||
Get absolute path to resource regardless of running directly or packaged using PyInstaller
|
||||
|
||||
Reference in New Issue
Block a user