From 868a67b44b970848b20b2711c3b3bf1ba98770cf Mon Sep 17 00:00:00 2001 From: tyler Date: Mon, 23 Mar 2026 23:44:34 -0700 Subject: [PATCH] raised issue fixes --- changelog.md | 14 ++- main.py | 281 +++++++++++++++++++++++++++++++-------------------- 2 files changed, 182 insertions(+), 113 deletions(-) diff --git a/changelog.md b/changelog.md index 6cdb069..9f1d852 100644 --- a/changelog.md +++ b/changelog.md @@ -1,17 +1,25 @@ -# Version 1.3.1 +# Version 1.4.0 +- This is potentially a save-changing release due to changes in how file paths are stored. Please update your project files to ensure compatibility +- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary - Added new parameters to the right side of the screen: MAX_SHIFT, T_MIN, T_MAX. Fixes [Issue 69](https://git.research.dezeeuw.ca/tyler/flares/issues/69) +- Added feedback when clicking an analysis option that opens up a new window. Fixes [Issue 20](https://git.research.dezeeuw.ca/tyler/flares/issues/20) +- Fixed an issue where projects can not be saved to a different drive letter on windows. Fixes [Issue 71](https://git.research.dezeeuw.ca/tyler/flares/issues/71) - Fixed an issue where the fOLD files were not included in the Windows version. Fixes [Issue 60](https://git.research.dezeeuw.ca/tyler/flares/issues/60) - Fixed an issue where the MacOS version would fail to persorm some analysis options. Fixes [Issue 63](https://git.research.dezeeuw.ca/tyler/flares/issues/63) - Fixed an issue where processing too many participants would cause the analysis button to not appear. Fixes [Issue 61](https://git.research.dezeeuw.ca/tyler/flares/issues/61) - Fixed an issue where the error message when a participant fails would not appear. Fixes [Issue 68](https://git.research.dezeeuw.ca/tyler/flares/issues/68) - Fixed an issue where pressing the 'Clear' button after loading a save would cause the application to crash. Fixes [Issue 67](https://git.research.dezeeuw.ca/tyler/flares/issues/67) +- Fixed an issue where group dropdowns in the Cross-Group viewer would not be updated correctly based on the other groups selected value. Fixes [Issue 49](https://git.research.dezeeuw.ca/tyler/flares/issues/49) +- Fixed an issue where scrollbars were still present after clearing all data. Fixes [Issue 70](https://git.research.dezeeuw.ca/tyler/flares/issues/70) +- Fixed an issue where 'Missing Events Bypass' did not work on the Cross-Group viewer. Fixes [Issue 64](https://git.research.dezeeuw.ca/tyler/flares/issues/64) +- Fixed an issue where bubbles loaded from a save would not resize correctly. Fixes [Issue 14](https://git.research.dezeeuw.ca/tyler/flares/issues/14) # Version 1.3.0 -- This is a save-changing release due to a new save file format. Please update your project files to ensure compatibility -- It is still potentially possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu +- This is potentially a save-changing release due to a new parameter being saved. Please update your project files to ensure compatibility +- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary - Fixed workers not releasing memory when processing multiple participants. Fixes [Issue 55](https://git.research.dezeeuw.ca/tyler/flares/issues/55) - Fixed part of an issue where memory could increase over time despite clicking the clear button. There is still some edge cases where this can occur - Fixed an issue when clearing a bubble, reloading the same file, and clicking it again would cause the app to crash. Fixes [Issue 57](https://git.research.dezeeuw.ca/tyler/flares/issues/57) diff --git a/main.py b/main.py index 73497c3..18e8de2 100644 --- a/main.py +++ b/main.py @@ -2263,14 +2263,36 @@ class FlaresBaseWidget(QWidget): return combo - def _get_checked_items(self, combo): - model = combo.model() - checked = [] - for i in range(2, model.rowCount()): # Start at 2 to skip dummy/toggle - item = model.item(i) - if item.checkState() == Qt.Checked: - checked.append(item.text()) - return checked + # def _get_checked_items(self, combo): + # model = combo.model() + # checked = [] + # for i in range(2, model.rowCount()): # Start at 2 to skip dummy/toggle + # item = model.item(i) + # if item.checkState() == Qt.Checked: + # checked.append(item.text()) + # return checked + + def _get_checked_items(self, combo=None): + target = combo if combo is not None else getattr(self, 'participant_dropdown', None) + + if target is None or target.model() is None: + return [] + + model = target.model() + checked_items = [] + + # Exclusion list: any item text that should never be treated as data + forbidden = {"Toggle All", "Select All", "", "Toggle"} + + for row in range(model.rowCount()): + item = model.item(row) + if item.checkState() == Qt.CheckState.Checked: + text = item.text() + # Only add if it's not a 'UI control' item + if text not in forbidden and not text.startswith("Toggle"): + checked_items.append(text) + + return checked_items def update_participant_dropdown_label(self, combo=None): @@ -2308,7 +2330,6 @@ class FlaresBaseWidget(QWidget): self._update_event_dropdown() - def update_image_index_dropdown_label(self): selected = self._get_checked_items(self.image_index_dropdown) if not selected: @@ -2320,51 +2341,95 @@ class FlaresBaseWidget(QWidget): def _update_event_dropdown(self): - selected_display_names = self._get_checked_items(self.participant_dropdown) - selected_file_paths = [] - for display_name in selected_display_names: - for fp, short_label in self.participant_map.items(): - expected_display = f"{short_label} ({os.path.basename(fp)})" - if display_name == expected_display: - selected_file_paths.append(fp) - break - - if not selected_file_paths: - self.event_dropdown.clear() - self.event_dropdown.addItem("") - return - - annotation_sets = [] - - for file_path in selected_file_paths: - raw = self.haemo_dict.get(file_path) - if raw is None or not hasattr(raw, "annotations"): - continue - annotations = set(raw.annotations.description) - annotation_sets.append(annotations) - - if not annotation_sets: - self.event_dropdown.clear() - self.event_dropdown.addItem("") - return + is_split_group = hasattr(self, 'participant_dropdown_a') and hasattr(self, 'participant_dropdown_b') bypass = False main_win = next((w for w in QApplication.topLevelWidgets() if w.objectName() == "MainApplication" or hasattr(w, "missing_events_bypass")), None) - if main_win: - bypass = main_win.missing_events_bypass + bypass = getattr(main_win, "missing_events_bypass", False) - if bypass: - final_annotations = set.union(*annotation_sets) + if is_split_group: + names_a = self._get_checked_items(self.participant_dropdown_a) + names_b = self._get_checked_items(self.participant_dropdown_b) + + if not names_a or not names_b: + self._clear_event_dropdown() + return + + map_a = getattr(self, 'participant_map_a', {}) + rev_a = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_a.items()} + sets_a = [] + for n in names_a: + raw = self.haemo_dict.get(rev_a.get(n)) + if raw and hasattr(raw, "annotations"): + sets_a.append(set(raw.annotations.description)) + + map_b = getattr(self, 'participant_map_b', {}) + rev_b = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_b.items()} + sets_b = [] + for n in names_b: + raw = self.haemo_dict.get(rev_b.get(n)) + if raw and hasattr(raw, "annotations"): + sets_b.append(set(raw.annotations.description)) + + if not sets_a or not sets_b: + self._clear_event_dropdown() + return + + if not bypass: + final_annotations = set.intersection(*(sets_a + sets_b)) + else: + all_events_a = {event for s in sets_a for event in s} + all_events_b = {event for s in sets_b for event in s} + + valid_a = set() + for event in all_events_a: + count = sum(1 for s in sets_a if event in s) + if count >= 2: + valid_a.add(event) + + valid_b = set() + for event in all_events_b: + count = sum(1 for s in sets_b if event in s) + if count >= 2: + valid_b.add(event) + + final_annotations = valid_a.intersection(valid_b) + else: - final_annotations = set.intersection(*annotation_sets) + names = self._get_checked_items(self.participant_dropdown) + if not names: + self._clear_event_dropdown() + return + + map_single = getattr(self, 'participant_map', {}) + rev_single = {f"{l} ({os.path.basename(fp)})": fp for fp, l in map_single.items()} + all_sets = [] + for n in names: + raw = self.haemo_dict.get(rev_single.get(n)) + if raw and hasattr(raw, "annotations"): + all_sets.append(set(raw.annotations.description)) + + if not all_sets: + self._clear_event_dropdown() + return + + if not bypass: + final_annotations = set.intersection(*all_sets) + else: + final_annotations = set.union(*all_sets) self.event_dropdown.clear() self.event_dropdown.addItem("") for ann in sorted(final_annotations): self.event_dropdown.addItem(ann) + def _clear_event_dropdown(self): + if hasattr(self, 'event_dropdown'): + self.event_dropdown.clear() + self.event_dropdown.addItem("") + def _connect_select_all_toggle(self, toggle_item, model): """Helper function to connect the Select All functionality.""" @@ -4187,69 +4252,31 @@ class GroupBrainViewerWidget(FlaresBaseWidget): self.showMaximized() def _update_group_b_options(self): - selected = self.group_a_dropdown.currentText() - self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected) + """Triggered when Group B changes: Update Group A to exclude B's choice""" + selected_b = self.group_b_dropdown.currentText() + + # Refresh Group A and exclude what was just picked in Group B + self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected_b) + + # Update the participants for Group B + self.update_participant_list_for_group(selected_b, self.participant_dropdown_b) self._update_event_dropdown() - group_b = self.group_b_dropdown.currentText() - self.update_participant_list_for_group(group_b, self.participant_dropdown_b) def _update_group_a_options(self): - selected = self.group_b_dropdown.currentText() - self._refresh_group_dropdown(self.group_a_dropdown, exclude=selected) + """Triggered when Group A changes: Update Group B to exclude A's choice""" + selected_a = self.group_a_dropdown.currentText() + + # Refresh Group B and exclude what was just picked in Group A + self._refresh_group_dropdown(self.group_b_dropdown, exclude=selected_a) + + # Update the participants for Group A + self.update_participant_list_for_group(selected_a, self.participant_dropdown_a) self._update_event_dropdown() - group_a = self.group_a_dropdown.currentText() - self.update_participant_list_for_group(group_a, self.participant_dropdown_a) def _on_participants_changed(self, item=None): self._update_event_dropdown() - def _update_event_dropdown(self): - participants_a = self._get_checked_items(self.participant_dropdown_a) - participants_b = self._get_checked_items(self.participant_dropdown_b) - - if not participants_a or not participants_b: - self.event_dropdown.clear() - self.event_dropdown.addItem("") - return - - selected_file_paths_a = [ - fp for display_name in participants_a - for fp, short_label in self.participant_map_a.items() - if display_name == f"{short_label} ({os.path.basename(fp)})" - ] - - selected_file_paths_b = [ - fp for display_name in participants_b - for fp, short_label in self.participant_map_b.items() - if display_name == f"{short_label} ({os.path.basename(fp)})" - ] - - all_selected_file_paths = set(selected_file_paths_a + selected_file_paths_b) - - if not all_selected_file_paths: - self.event_dropdown.clear() - self.event_dropdown.addItem("") - return - - annotation_sets = [] - for file_path in all_selected_file_paths: - raw = self.haemo_dict.get(file_path) - if raw is None or not hasattr(raw, "annotations"): - continue - annotation_sets.append(set(raw.annotations.description)) - - if not annotation_sets: - self.event_dropdown.clear() - self.event_dropdown.addItem("") - return - - shared_annotations = set.intersection(*annotation_sets) - self.event_dropdown.clear() - self.event_dropdown.addItem("") - for ann in sorted(shared_annotations): - self.event_dropdown.addItem(ann) - def _refresh_group_dropdown(self, dropdown, exclude): current = dropdown.currentText() dropdown.blockSignals(True) @@ -4266,7 +4293,6 @@ class GroupBrainViewerWidget(FlaresBaseWidget): dropdown.blockSignals(False) - def _get_file_paths_from_labels(self, labels, group_name): file_paths = [] @@ -4441,31 +4467,35 @@ class ViewerLauncherWidget(QWidget): for file_path, config in config_dict.items() } + def launch(func, btn, *args): + func(*args) + self._trigger_success(btn) + layout = QVBoxLayout(self) btn1 = QPushButton("Open Participant Viewer") - btn1.clicked.connect(lambda: self.open_participant_viewer(haemo_dict, fig_bytes_dict)) + btn1.clicked.connect(lambda: launch(self.open_participant_viewer, btn1, haemo_dict, fig_bytes_dict)) btn2 = QPushButton("Open Participant Brain Viewer") - btn2.clicked.connect(lambda: self.open_participant_brain_viewer(haemo_dict, cha_dict)) + btn2.clicked.connect(lambda: launch(self.open_participant_brain_viewer, btn2, haemo_dict, cha_dict)) btn3 = QPushButton("Open Participant Fold Channels Viewer") - btn3.clicked.connect(lambda: self.open_participant_fold_channels_viewer(haemo_dict, cha_dict)) + btn3.clicked.connect(lambda: launch(self.open_participant_fold_channels_viewer, btn3, haemo_dict, cha_dict)) btn7 = QPushButton("Open Functional Connectivity Viewer [BETA]") - btn7.clicked.connect(lambda: self.open_participant_functional_connectivity_viewer(haemo_dict, epochs_dict)) + btn7.clicked.connect(lambda: launch(self.open_participant_functional_connectivity_viewer, btn7, haemo_dict, epochs_dict)) btn8 = QPushButton("Open Group Functional Connectivity Viewer [BETA]") - btn8.clicked.connect(lambda: self.open_group_functional_connectivity_viewer(haemo_dict, group_dict, config_dict)) + btn8.clicked.connect(lambda: launch(self.open_group_functional_connectivity_viewer, btn8, haemo_dict, group_dict, config_dict)) btn4 = QPushButton("Open Inter-Group Viewer") - btn4.clicked.connect(lambda: self.open_group_viewer(haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group_dict)) + btn4.clicked.connect(lambda: launch(self.open_group_viewer, btn4, haemo_dict, cha_dict, df_ind, design_matrix, contrast_results_dict, group_dict)) btn5 = QPushButton("Open Cross Group Brain Viewer") - btn5.clicked.connect(lambda: self.open_group_brain_viewer(haemo_dict, df_ind, design_matrix, group_dict, contrast_results_dict)) + btn5.clicked.connect(lambda: launch(self.open_group_brain_viewer, btn5, haemo_dict, df_ind, design_matrix, group_dict, contrast_results_dict)) btn6 = QPushButton("Open Export Data As CSV Viewer") - btn6.clicked.connect(lambda: self.open_export_data_as_csv_viewer(haemo_dict, cha_dict, df_ind, design_matrix, group_dict, contrast_results_dict)) + btn6.clicked.connect(lambda: launch(self.open_export_data_as_csv_viewer, btn6, haemo_dict, cha_dict, df_ind, design_matrix, group_dict, contrast_results_dict)) layout.addWidget(btn1) layout.addWidget(btn2) @@ -4508,6 +4538,20 @@ class ViewerLauncherWidget(QWidget): self.export_data_as_csv_viewer = ExportDataAsCSVViewerWidget(haemo_dict, cha_dict, df_ind, design_matrix, group, contrast_results_dict) self.export_data_as_csv_viewer.show() + def _trigger_success(self, button): + """Temporarily adds a green checkmark to the button text.""" + original_text = button.text() + button.setText(f"{original_text} ✔") + button.setStyleSheet("color: green; font-weight: bold;") + + # Revert after 1 second + QTimer.singleShot(1000, lambda: self._revert_button(button, original_text)) + + def _revert_button(self, button, original_text): + button.setText(original_text) + button.setStyleSheet("") + + class MainApplication(QMainWindow): """ @@ -4856,6 +4900,12 @@ class MainApplication(QMainWindow): except: pass widget.deleteLater() + + self.bubble_layout.setSpacing(0) + self.bubble_layout.setContentsMargins(0, 0, 0, 0) + self.bubble_container.setMinimumSize(0, 0) + self.bubble_container.resize(0, 0) + self.scroll_area.updateGeometry() # Data Purge self.bubble_widgets = {} @@ -4885,7 +4935,7 @@ class MainApplication(QMainWindow): self.statusBar().showMessage("All data has been cleared.") - #NOTE: leave this here for now + #NOTE: leave this here for now. needs other parts uncommented to work # self.check_memory_leak() # self.find_referrers() @@ -5109,12 +5159,12 @@ class MainApplication(QMainWindow): project_dir = project_path.parent file_list = [ - str(PurePosixPath(os.path.relpath(Path(bubble.file_path).resolve(), project_dir))) + self._get_safe_path(bubble.file_path, project_dir) for bubble in self.bubble_widgets.values() ] progress_states = { - str(PurePosixPath(os.path.relpath(Path(bubble.file_path).resolve(), project_dir))): bubble.current_step + self._get_safe_path(bubble.file_path, project_dir): bubble.current_step for bubble in self.bubble_widgets.values() } @@ -5122,9 +5172,8 @@ class MainApplication(QMainWindow): for full_path, meta in self.metadata_cache.items(): try: # Resolve to absolute to be safe, then make relative to project_dir - abs_path = str(Path(full_path).resolve()) - rel_path = str(PurePosixPath(os.path.relpath(abs_path, project_dir))) - rel_metadata[rel_path] = meta + safe_path = self._get_safe_path(full_path, project_dir) + rel_metadata[safe_path] = meta except Exception as e: print(f"Metadata conversion failed for {full_path}: {e}") @@ -5182,6 +5231,18 @@ class MainApplication(QMainWindow): QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}") + def _get_safe_path(self, target_path, start_dir): + try: + # Convert both to absolute paths first + target = Path(target_path).resolve() + base = Path(start_dir).resolve() + + rel = os.path.relpath(target, base) + return str(PurePosixPath(rel)) + except ValueError: + return str(PurePosixPath(target)) + + def load_project(self): filename, _ = QFileDialog.getOpenFileName( self, "Load Project", "", "FLARE Project (*.flare)"