improved project saving capabilities

This commit is contained in:
2026-08-03 22:34:41 -07:00
parent 0b52d2b0bc
commit a0199737f5
6 changed files with 1512 additions and 1125 deletions
+423 -297
View File
@@ -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__()
+15 -1
View File
@@ -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