diff --git a/src/eegprep/functions/guifunc/qt.py b/src/eegprep/functions/guifunc/qt.py index c8eb6f8e..2aed9e6a 100644 --- a/src/eegprep/functions/guifunc/qt.py +++ b/src/eegprep/functions/guifunc/qt.py @@ -107,6 +107,8 @@ class QtDialogRenderer: _set_reref_mode: Any _set_enabled: Any _show_help: Any + _clear_widgets: Any + _select_study_components: Any _read_widget: Any def run( @@ -363,6 +365,14 @@ def _connect_callback(self, callback: CallbackSpec | None, widgets: dict[str, An source = widgets.get(params["source"]) if source is not None: source.clicked.connect(lambda: self._navigate_channel(widgets, params)) + elif callback.name == "clear_widgets": + source = widgets.get(params["button"]) + if source is not None: + source.clicked.connect(lambda: self._clear_widgets(widgets, params)) + elif callback.name == "select_study_components": + button = widgets.get(params["button"]) + if button is not None: + button.clicked.connect(lambda: self._select_study_components(button, widgets, params)) def _run_tf_cycle_calc(self, button: Any, widgets: dict[str, Any], params: Mapping[str, Any]) -> None: _qt_core, qt_widgets = _require_qt() @@ -1347,6 +1357,55 @@ def _show_help(_qt_widgets: Any, dialog: Any, spec: DialogSpec) -> None: dialog._eegprep_help_dialog = pophelp(spec.help_text or spec.function_name, parent=dialog) +def _clear_widgets(widgets: Mapping[str, Any], params: Mapping[str, Any]) -> None: + labels = params.get("labels", {}) + values = params.get("values", {}) + for tag in params.get("targets", ()): + widget = widgets.get(tag) + if widget is None: + continue + if hasattr(widget, "setText"): + widget.setText(str(labels.get(tag, ""))) + if hasattr(widget, "setProperty"): + widget.setProperty(_VALUE_PROPERTY, values.get(tag)) + + +def _select_study_components(button: Any, widgets: Mapping[str, Any], params: Mapping[str, Any]) -> None: + count = int(params.get("count", 0)) + if count <= 0: + _qt_core, qt_widgets = _require_qt() + qt_widgets.QMessageBox.warning(button, "Warning", "No components found for this dataset. Run ICA first.") + return + + labels = [f"IC {i + 1}" for i in range(count)] + current = button.property(_VALUE_PROPERTY) or params.get("initial", []) + if isinstance(current, str): + initial = current + else: + initial = " ".join(str(i) for i in current) + + chanlist, chanliststr, _allchanstr = pop_chansel( + labels, + withindex="on", + select=initial, + parent=button, + ) + + if chanlist is None: + return + + button.setProperty(_VALUE_PROPERTY, list(chanlist)) + + if not chanlist: + button.setText("All comp.") + else: + if len(chanlist) > 3: + label = f"Comp.: {' '.join(str(i) for i in chanlist[:2])} ..." + else: + label = f"Comp.: {' '.join(str(i) for i in chanlist)}" + button.setText(label) + + def _read_widget(widget: Any) -> Any: stored_value = widget.property(_VALUE_PROPERTY) if stored_value is not None: @@ -1462,6 +1521,8 @@ def _firpm_default_devs(amplitudes: list[float]) -> list[float]: "_set_reref_mode", "_set_enabled", "_show_help", + "_clear_widgets", + "_select_study_components", "_read_widget", ) for _helper_name in _QT_RENDERER_STATIC_HELPERS: diff --git a/src/eegprep/functions/studyfunc/pop_study.py b/src/eegprep/functions/studyfunc/pop_study.py index cb4d233e..6dd25fea 100644 --- a/src/eegprep/functions/studyfunc/pop_study.py +++ b/src/eegprep/functions/studyfunc/pop_study.py @@ -144,8 +144,10 @@ def pop_study_dialog_spec(STUDY: dict[str, Any] | None, ALLEEG: list[dict[str, A ] for index in range(1, visible_rows + 1): info = datasetinfo[index - 1] if index <= len(datasetinfo) else {} + component_values = _component_values(info.get("comps")) browse_tag = f"dataset_{index}_browse" filename_tag = f"dataset_{index}_filename" + components_tag = f"dataset_{index}_components" controls.extend( ( ControlSpec("text", str(index)), @@ -197,15 +199,39 @@ def pop_study_dialog_spec(STUDY: dict[str, Any] | None, ALLEEG: list[dict[str, A ), ControlSpec( "pushbutton", - "All comp.", - tag=f"dataset_{index}_components", - callback=_button_callback(coming_soon, f"dataset_{index}_components"), + _format_components_button(component_values), + tag=components_tag, + value=component_values, + callback=CallbackSpec( + "select_study_components", + { + "button": components_tag, + "count": _dataset_component_count(datasets, index), + "initial": component_values, + }, + ), ), ControlSpec( "pushbutton", "Clear", tag=f"dataset_{index}_clear", - callback=_button_callback(coming_soon, f"dataset_{index}_clear"), + callback=CallbackSpec( + "clear_widgets", + { + "button": f"dataset_{index}_clear", + "targets": [ + filename_tag, + f"dataset_{index}_subject", + f"dataset_{index}_session", + f"dataset_{index}_run", + f"dataset_{index}_condition", + f"dataset_{index}_group", + components_tag, + ], + "labels": {components_tag: "All comp."}, + "values": {components_tag: []}, + }, + ), ), ) ) @@ -216,21 +242,6 @@ def pop_study_dialog_spec(STUDY: dict[str, Any] | None, ALLEEG: list[dict[str, A "Important note: Removed datasets will not be saved before being deleted from EEGPrep memory", ), ControlSpec("spacer"), - ControlSpec( - "pushbutton", - "<", - tag="previous_page", - callback=_button_callback(coming_soon, "previous_page"), - ), - ControlSpec("text", "Page 1"), - ControlSpec( - "pushbutton", - ">", - tag="next_page", - callback=_button_callback(coming_soon, "next_page"), - ), - ControlSpec("spacer"), - ControlSpec("spacer"), ControlSpec("checkbox", "", tag="delete_cluster_info", value=False), ControlSpec( "text", @@ -241,7 +252,7 @@ def pop_study_dialog_spec(STUDY: dict[str, Any] | None, ALLEEG: list[dict[str, A header_geometry = (0.2, 1.05, 0.35, 0.4, 0.35, 0.25, 0.6, 0.4, 0.6, 0.3) geometry = [(1,), (0.2, 1, 3.5), (0.2, 1, 3.5), (0.2, 1, 3.5), (1,), header_geometry] geometry.extend(header_geometry for _index in range(visible_rows)) - geometry.extend(((1,), (1, 0.2, 0.3, 0.2, 1), (1,), (0.14, 3))) + geometry.extend(((1,), (1,), (0.14, 3))) return DialogSpec( title=title, controls=tuple(controls), @@ -275,11 +286,6 @@ def pop_study_dialog_spec(STUDY: dict[str, Any] | None, ALLEEG: list[dict[str, A min-width: 76px; max-width: 76px; } - QDialog#pop_study QPushButton#previous_page, - QDialog#pop_study QPushButton#next_page { - min-width: 46px; - max-width: 46px; - } """, known_differences=( "EEGPrep Phase 5a edits loaded dataset metadata; dataset browsing is provided by pop_studywizard.", @@ -311,6 +317,9 @@ def _dataset_row_commands(datasets: list[dict[str, Any]], result: dict[str, Any] commands.extend(["session", parse_optional_int_text(result.get(f"{prefix}session"))]) if f"{prefix}run" in result: commands.extend(["run", parse_optional_int_text(result.get(f"{prefix}run"))]) + components = result.get(f"{prefix}components") + if isinstance(components, list): + commands.extend(["comps", components]) return commands @@ -345,4 +354,37 @@ def _button_callback(template: CallbackSpec, button: str) -> CallbackSpec: return CallbackSpec(template.name, params, template.matlab_callback) +def _dataset_component_count(datasets: list[dict[str, Any]], index: int) -> int: + if index > len(datasets): + return 0 + eeg = datasets[index - 1] + weights = eeg.get("icaweights") + if weights is not None and hasattr(weights, "shape"): + return int(weights.shape[0]) + return 0 + + +def _component_values(comps: Any) -> list[Any]: + if comps is None: + return [] + if isinstance(comps, str): + return comps.split() + if hasattr(comps, "ravel") and hasattr(comps, "tolist"): + return list(comps.ravel().tolist()) + if hasattr(comps, "tolist"): + comps = comps.tolist() + if isinstance(comps, (list, tuple, set)): + return list(comps) + return [comps] + + +def _format_components_button(comps: Any) -> str: + values = _component_values(comps) + if not values: + return "All comp." + if len(values) > 3: + return f"Comp.: {' '.join(str(i) for i in values[:2])} ..." + return f"Comp.: {' '.join(str(i) for i in values)}" + + __all__ = ["pop_study", "pop_study_dialog_spec"] diff --git a/src/eegprep/functions/studyfunc/std_editset.py b/src/eegprep/functions/studyfunc/std_editset.py index d17ac405..3dd645bb 100644 --- a/src/eegprep/functions/studyfunc/std_editset.py +++ b/src/eegprep/functions/studyfunc/std_editset.py @@ -127,7 +127,7 @@ def _flatten_commands(commands: Any) -> list[Any]: for item in commands: if isinstance(item, dict): flat.extend(_flatten_commands(item)) - elif isinstance(item, (list, tuple)): + elif _is_command_sequence(item): flat.extend(_flatten_commands(list(item))) else: flat.append(item) @@ -136,6 +136,12 @@ def _flatten_commands(commands: Any) -> list[Any]: return flat +def _is_command_sequence(value: Any) -> bool: + if not isinstance(value, (list, tuple)): + return False + return len(value) % 2 == 0 and all(isinstance(value[index], str) for index in range(0, len(value), 2)) + + def _metadata_value(key: str, value: Any) -> Any: if key in {"session", "run"}: return parse_optional_int_text(value) diff --git a/tests/test_gui_pop_study.py b/tests/test_gui_pop_study.py index 77890094..76e499bf 100644 --- a/tests/test_gui_pop_study.py +++ b/tests/test_gui_pop_study.py @@ -1,5 +1,7 @@ import ast +import numpy as np + from eegprep.functions.guifunc.spec import controls_by_tag from eegprep.functions.studyfunc.pop_study import pop_study, pop_study_dialog_spec from eegprep.functions.studyfunc.pop_studydesign import pop_studydesign, pop_studydesign_dialog_spec @@ -84,6 +86,43 @@ def test_pop_study_gui_updates_metadata_and_returns_python_history(): assert namespace["ALLEEG"][0]["condition"] == "standard" +def test_pop_study_gui_ignores_untouched_components_button_label(): + study, alleeg = _study_inputs() + renderer = _Renderer( + { + "name": "Edited", + "task": "", + "notes": "", + "dataset_1_subject": "S02", + "dataset_1_components": "All comp.", + } + ) + + edited, _edited_alleeg, command = pop_study(study, alleeg, gui=True, renderer=renderer, return_com=True) + + assert edited["datasetinfo"][0]["subject"] == "S02" + assert "comps" not in command + ast.parse(command) + + +def test_pop_study_gui_records_selected_components(): + study, alleeg = _study_inputs() + renderer = _Renderer( + { + "name": "Study", + "task": "", + "notes": "", + "dataset_1_components": [1, 2], + } + ) + + edited, _edited_alleeg, command = pop_study(study, alleeg, gui=True, renderer=renderer, return_com=True) + + assert edited["datasetinfo"][0]["comps"] == [1, 2] + assert "'comps', [1, 2]" in command + ast.parse(command) + + def test_pop_study_gui_cancel_is_noop(): study, alleeg = _study_inputs() @@ -94,6 +133,17 @@ def test_pop_study_gui_cancel_is_noop(): assert command == "" +def test_pop_study_dialog_spec_handles_numpy_component_lists(): + study, alleeg = _study_inputs() + study["datasetinfo"][0]["comps"] = np.array([1, 2, 3, 4]) + + spec = pop_study_dialog_spec(study, alleeg) + controls = controls_by_tag(spec) + + assert controls["dataset_1_components"].string == "Comp.: 1 2 ..." + assert controls["dataset_1_components"].value == [1, 2, 3, 4] + + def test_pop_studydesign_dialog_spec_lists_factors_and_current_design(): first = create_test_eeg(n_channels=2, n_samples=10) first.update({"setname": "one", "subject": "S01", "condition": "target", "filename": "one.set"})