diff --git a/src/derzug/views/orange.py b/src/derzug/views/orange.py index 6fe74a3..c4665ba 100644 --- a/src/derzug/views/orange.py +++ b/src/derzug/views/orange.py @@ -3444,7 +3444,9 @@ def set_scheme(self, new_scheme, freeze_creation=False): def _reemit_restored_source_widgets(self) -> None: """Re-emit restored source-widget outputs after workflow reload settles.""" + from derzug.widgets.select import Select from derzug.widgets.spool import Spool + from derzug.widgets.waterfall import Waterfall document = self.current_document() scheme = getattr(document, "scheme", lambda: None)() @@ -3457,6 +3459,33 @@ def _reemit_restored_source_widgets(self) -> None: widget.run() else: widget._emit_current_output() + elif ( + isinstance(widget, Waterfall) + and widget._patch is None + and widget._pending_saved_selection_restore + ): + widget._emit_current_selection() + for link in scheme.links: + if not getattr(link, "enabled", True): + continue + if getattr(link.source_channel, "name", "") != "Select Params": + continue + if getattr(link.sink_channel, "name", "") != "Select Params": + continue + source_widget = scheme.widget_for_node(link.source_node) + sink_widget = scheme.widget_for_node(link.sink_node) + if not isinstance(source_widget, Waterfall) or not isinstance( + sink_widget, Select + ): + continue + if ( + source_widget._patch is not None + or not source_widget._pending_saved_selection_restore + ): + continue + select_params = source_widget._saved_select_params() + if select_params is not None: + sink_widget.set_select_params(select_params) def _collect_open_widget_node_ids(self) -> list[int]: """Return indices of nodes whose widget windows are currently visible. diff --git a/src/derzug/widgets/select.py b/src/derzug/widgets/select.py index 8ba866e..e26e88d 100644 --- a/src/derzug/widgets/select.py +++ b/src/derzug/widgets/select.py @@ -233,6 +233,7 @@ def set_select_params(self, select_params: SelectParams | None) -> None: self._restore_manual_selection_after_params() else: self._apply_external_select_params_if_ready() + self._selection_refresh_panel() self._emit_selected_output() def _selection_on_state_changed(self) -> None: @@ -254,6 +255,89 @@ def _rebind_dynamic_controls(self) -> None: """Rebuild selection controls after a new input source arrives.""" self._selection_refresh_panel() + def _selection_refresh_panel(self) -> None: + """Push the current selection state into the controls.""" + if not self._refresh_external_select_params_panel(): + super()._selection_refresh_panel() + + def _refresh_external_select_params_panel(self) -> bool: + """Show read-only SelectParams range controls without patch-mode input.""" + if ( + self._external_select_params is None + or self._input_kind == "patch" + or self._selection_panel is None + ): + return False + patch = self._spool_display_patch() + state = self._external_select_params_display_state(patch) + if state is None: + return False + panel = self._selection_panel + panel.set_mode(state.mode) + panel.set_patch_basis(state.patch.basis) + panel.rebuild_patch_rows( + tuple(state.patch.extents), + state.patch.extents, + self._selection_preferred_patch_dims, + ) + panel.set_patch_enabled(state.patch.enabled) + panel.set_patch_ranges( + state.patch.ranges, + state.patch.extents, + tuple(self._external_select_params.kwargs), + ) + panel.set_patch_editable(False) + self._selection_patch_checkboxes = panel.patch_checkboxes + self._selection_patch_edits = panel.patch_edits + return True + + def _external_select_params_display_state( + self, patch: dc.Patch | None + ) -> SelectionState | None: + """Return patch-style state for displaying external SelectParams.""" + params = self._external_select_params + if params is None or not params.kwargs: + return None + state = SelectionState() + if patch is not None: + state.apply_select_params(params, patch) + return state + + basis = PatchSelectionBasis.ABSOLUTE + if params.relative: + basis = PatchSelectionBasis.RELATIVE + elif params.samples: + basis = PatchSelectionBasis.SAMPLES + ranges: dict[str, tuple[object, object]] = {} + for dim, value_range in params.kwargs.items(): + try: + low, high = value_range + except (TypeError, ValueError): + continue + ranges[dim] = (low, high) + if not ranges: + return None + state.mode = SelectionMode.PATCH + state.patch.basis = basis + state.patch.extents = dict(ranges) + state.patch.ranges = ranges + state.patch.enabled = {dim: True for dim in ranges} + return state + + def _spool_display_patch(self) -> dc.Patch | None: + """Return a representative spool patch for SelectParams display.""" + spool = self._spool + if spool is None: + return None + try: + return spool[0] + except Exception: + pass + try: + return next(iter(spool)) + except Exception: + return None + def _emit_selected_output(self) -> None: """Trigger the standard run lifecycle so _on_result is always the send site.""" self.run() @@ -594,6 +678,12 @@ def _apply_external_select_params_if_ready(self) -> bool: self._selection_refresh_panel() return True + def _selection_show_full_extent_dims(self) -> tuple[str, ...]: + """Show incoming SelectParams values even when they match patch extents.""" + if self._external_select_params is None: + return () + return tuple(self._external_select_params.kwargs) + def _restore_manual_selection_after_params(self) -> None: """Restore editable patch controls after external params disconnect.""" if self._input_kind != "patch" or self._patch is None: diff --git a/src/derzug/widgets/selection.py b/src/derzug/widgets/selection.py index 769a037..0331e2d 100644 --- a/src/derzug/widgets/selection.py +++ b/src/derzug/widgets/selection.py @@ -980,8 +980,10 @@ def set_patch_ranges( self, ranges: dict[str, tuple[Any, Any]], extents: dict[str, tuple[Any, Any]], + show_full_extent_dims: tuple[str, ...] = (), ) -> None: """Write current patch ranges into the visible line edits.""" + show_full_extent = set(show_full_extent_dims) with self.syncing(): for dim, edits in self.patch_edits.items(): if dim not in ranges: @@ -991,12 +993,18 @@ def set_patch_ranges( low_edit, high_edit = edits low, high = ranges[dim] full_low, full_high = extents[dim] - desired_low = ( - "" if _values_equal(low, full_low) else _format_coord_value(low) - ) - desired_high = ( - "" if _values_equal(high, full_high) else _format_coord_value(high) - ) + if dim in show_full_extent: + desired_low = _format_coord_value(low) + desired_high = _format_coord_value(high) + else: + desired_low = ( + "" if _values_equal(low, full_low) else _format_coord_value(low) + ) + desired_high = ( + "" + if _values_equal(high, full_high) + else _format_coord_value(high) + ) current_low = low_edit.text() current_high = high_edit.text() if desired_low == "": @@ -1287,6 +1295,7 @@ def _selection_refresh_panel(self) -> None: panel.set_patch_ranges( self._selection_state.patch.ranges, self._selection_state.patch.extents, + self._selection_show_full_extent_dims(), ) panel.set_spool_filters( self._selection_state.spool.options, @@ -1305,6 +1314,10 @@ def _selection_set_patch_editable(self, editable: bool) -> None: if self._selection_panel is not None: self._selection_panel.set_patch_editable(self._selection_patch_editable) + def _selection_show_full_extent_dims(self) -> tuple[str, ...]: + """Return dims whose full-extent ranges should be shown, not blanked.""" + return () + def _selection_request_panel_refresh(self) -> None: """Request a visible selection-panel refresh through the host widget.""" self._selection_refresh_panel() diff --git a/src/derzug/widgets/waterfall.py b/src/derzug/widgets/waterfall.py index c6231f9..da08811 100644 --- a/src/derzug/widgets/waterfall.py +++ b/src/derzug/widgets/waterfall.py @@ -56,7 +56,11 @@ MultiDimPlotControlsMixin, format_nd_coord_value, ) -from derzug.widgets.selection import SelectionControlsMixin +from derzug.widgets.selection import ( + PatchSelectionBasis, + SelectionControlsMixin, + SelectionState, +) from derzug.workflow import Task from derzug.workflow.widget_tasks import PatchSelectionWithParamsTask @@ -1477,7 +1481,12 @@ def _emit_current_selection(self) -> None: self.Warning.empty_selection.clear() if self._patch is None: self.Outputs.patch.send(None) - self.Outputs.select_params.send(None) + select_params = ( + self._saved_select_params() + if self._pending_saved_selection_restore + else None + ) + self.Outputs.select_params.send(select_params) return if self._roi is not None and not self._restore_saved_roi_after_render: self._update_selection_from_roi(notify=False) @@ -1985,6 +1994,34 @@ def _load_saved_selection_state(self) -> dict[str, Any] | None: return None return {"basis": basis_name, "rows": rows} + def _saved_select_params(self) -> SelectParams | None: + """Return saved selection settings as public SelectParams.""" + payload = self._load_saved_selection_state() + if payload is None: + return None + basis_name = str(payload.get("basis", "")).strip() + rows = payload.get("rows") + if not isinstance(rows, list): + return None + kwargs: dict[str, tuple[object, object]] = {} + for row in rows: + if not isinstance(row, dict) or not row.get("enabled", True): + continue + dim = row.get("dim") + if not isinstance(dim, str) or not dim: + continue + kwargs[dim] = ( + SelectionState._deserialize_value(row.get("low")), + SelectionState._deserialize_value(row.get("high")), + ) + if not kwargs: + return None + return SelectParams( + kwargs=kwargs, + relative=basis_name == PatchSelectionBasis.RELATIVE.value, + samples=basis_name == PatchSelectionBasis.SAMPLES.value, + ) + def _prime_saved_selection_state(self) -> None: """Seed selection state from stored workflow settings before patch input.""" primed = self._selection_state.prime_patch_state_from_settings( diff --git a/tests/test_views/test_orange_qt.py b/tests/test_views/test_orange_qt.py index f0ac536..0af9a1e 100644 --- a/tests/test_views/test_orange_qt.py +++ b/tests/test_views/test_orange_qt.py @@ -3018,6 +3018,60 @@ def test_workflow_roundtrip_preserves_select_patch_basis_and_sample_values( assert low_edit.text() == "100" assert high_edit.text() == "200" + def test_loaded_waterfall_select_params_populate_params_only_select( + self, derzug_app, tmp_path, qapp, orange_workflow + ): + """Saved Waterfall params should populate Select without a data link.""" + window = derzug_app.window + workflow = orange_workflow( + ( + ("Spool", "spool-node"), + ("Waterfall", "waterfall-node"), + ("Select", "select-node"), + ), + ( + ("spool-node", "Patch", "waterfall-node", "Patch"), + ("waterfall-node", "Select Params", "select-node", "Select Params"), + ), + ) + waterfall_widget = workflow.widgets_by_title["waterfall-node"] + selected = { + "distance": (643.7278839133311, 793.7278839133311), + "time": (0.013890560242722401, 0.03889056024272312), + } + waterfall_widget.saved_selection_basis = "absolute" + waterfall_widget.saved_selection_ranges = [ + { + "dim": dim, + "enabled": True, + "low": {"kind": "float", "value": low}, + "high": {"kind": "float", "value": high}, + } + for dim, (low, high) in selected.items() + ] + waterfall_widget.saved_selection_has_roi = True + + workflow_path = tmp_path / "waterfall-select-params-only.ows" + assert window.save_scheme_to(workflow.scheme, str(workflow_path)) + + window.load_scheme(str(workflow_path)) + qapp.processEvents() + qapp.processEvents() + loaded_scheme = window.current_document().scheme() + loaded_node = next( + node for node in loaded_scheme.nodes if node.title == "select-node" + ) + loaded_widget = loaded_scheme.widget_for_node(loaded_node) + + assert loaded_widget._selection_panel.mode_label.text() == "Range selection" + assert set(loaded_widget._selection_patch_edits) == set(selected) + for dim, (low, high) in selected.items(): + low_edit, high_edit = loaded_widget._selection_patch_edits[dim] + assert low_edit.text() == format_display(low) + assert high_edit.text() == format_display(high) + assert not low_edit.isEnabled() + assert not high_edit.isEnabled() + def test_workflow_roundtrip_preserves_select_spool_filter_values( self, derzug_app, tmp_path, qapp, orange_workflow ): diff --git a/tests/test_widgets/test_select.py b/tests/test_widgets/test_select.py index 79d4b15..1cc6743 100644 --- a/tests/test_widgets/test_select.py +++ b/tests/test_widgets/test_select.py @@ -227,6 +227,98 @@ def test_select_params_input_drives_patch_controls_read_only( assert high_edit.isEnabled() assert select_widget._selection_panel.patch_basis_combo.isEnabled() + def test_select_params_values_show_when_matching_patch_extents( + self, select_widget, monkeypatch + ): + """Waterfall-style params should display even on an already cropped patch.""" + received = capture_output(select_widget.Outputs.patch, monkeypatch) + patch = dc.get_example_patch("example_event_2") + distance = patch.get_array("distance") + selected = (distance[10], distance[20]) + params = SelectParams(kwargs={"distance": selected}) + cropped = patch.select(copy=False, distance=selected) + + select_widget.set_patch(cropped) + select_widget.set_select_params(params) + + assert received[-1].shape == cropped.shape + low_edit, high_edit = select_widget._selection_patch_edits["distance"] + assert low_edit.text() == format_display(selected[0]) + assert high_edit.text() == format_display(selected[1]) + assert not low_edit.isEnabled() + assert not high_edit.isEnabled() + assert not select_widget._selection_panel.patch_basis_combo.isEnabled() + + def test_multi_dim_select_params_show_all_param_values( + self, select_widget, monkeypatch + ): + """Multiple Waterfall params should expand to visible read-only rows.""" + received = capture_output(select_widget.Outputs.patch, monkeypatch) + patch = dc.get_example_patch("example_event_2") + time = patch.get_array("time") + distance = patch.get_array("distance") + selected = { + "time": (time[10], time[20]), + "distance": (distance[30], distance[40]), + } + params = SelectParams(kwargs=selected) + cropped = patch.select(copy=False, **selected) + + select_widget.set_select_params(params) + select_widget.set_patch(cropped) + + assert received[-1].shape == cropped.shape + assert set(selected).issubset(select_widget._selection_patch_edits) + for dim, (low, high) in selected.items(): + low_edit, high_edit = select_widget._selection_patch_edits[dim] + assert low_edit.text() == format_display(low) + assert high_edit.text() == format_display(high) + assert not low_edit.isEnabled() + assert not high_edit.isEnabled() + assert not select_widget._selection_panel.patch_basis_combo.isEnabled() + + def test_select_params_only_show_all_param_values(self, select_widget, monkeypatch): + """Waterfall params should display even without a data input link.""" + received = capture_output(select_widget.Outputs.patch, monkeypatch) + selected = { + "distance": (643.7278839133311, 793.7278839133311), + "time": (0.013890560242722401, 0.03889056024272312), + } + params = SelectParams(kwargs=selected) + + select_widget.set_select_params(params) + + assert received[-1] is None + assert select_widget._selection_panel.mode_label.text() == "Range selection" + assert set(select_widget._selection_patch_edits) == set(selected) + for dim, (low, high) in selected.items(): + low_edit, high_edit = select_widget._selection_patch_edits[dim] + assert low_edit.text() == format_display(low) + assert high_edit.text() == format_display(high) + assert not low_edit.isEnabled() + assert not high_edit.isEnabled() + assert not select_widget._selection_panel.patch_basis_combo.isEnabled() + + def test_hidden_select_params_only_refreshes_range_controls(self, qtbot): + """Params-only workflows should populate Select before its window opens.""" + selected = { + "distance": (643.7278839133311, 793.7278839133311), + "time": (0.013890560242722401, 0.03889056024272312), + } + + with widget_context(Select) as widget: + widget.set_select_params(SelectParams(kwargs=selected)) + qtbot.wait(10) + + assert widget._selection_panel.mode_label.text() == "Range selection" + assert set(widget._selection_patch_edits) == set(selected) + for dim, (low, high) in selected.items(): + low_edit, high_edit = widget._selection_patch_edits[dim] + assert low_edit.text() == format_display(low) + assert high_edit.text() == format_display(high) + assert not low_edit.isEnabled() + assert not high_edit.isEnabled() + def test_build_status_text_for_patch_selection(self, select_widget): """Patch status text should come from the compact status builder.""" patch = dc.get_example_patch("example_event_2") @@ -682,6 +774,36 @@ def test_select_params_filter_spool_patches(self, select_widget, monkeypatch): assert selected_patch.shape == patch.select(time=(time[10], time[20])).shape assert selected_patch.shape != patch.shape + def test_spool_select_params_show_waterfall_range_controls( + self, select_widget, monkeypatch + ): + """Spool inputs should still display Waterfall SelectParams ranges.""" + received = capture_output(select_widget.Outputs.spool, monkeypatch) + patch = dc.get_example_patch("example_event_2") + time = patch.get_array("time") + distance = patch.get_array("distance") + selected = { + "time": (time[10], time[20]), + "distance": (distance[30], distance[40]), + } + params = SelectParams(kwargs=selected) + + select_widget.set_spool(dc.spool([patch])) + select_widget.set_select_params(params) + wait_for_widget_idle(select_widget, timeout=5.0) + + expected = dc.spool([patch]).select(**selected) + assert len(received[-1].get_contents()) == len(expected.get_contents()) + assert select_widget._selection_panel.mode_label.text() == "Range selection" + assert set(selected).issubset(select_widget._selection_patch_edits) + for dim, (low, high) in selected.items(): + low_edit, high_edit = select_widget._selection_patch_edits[dim] + assert low_edit.text() == format_display(low) + assert high_edit.text() == format_display(high) + assert not low_edit.isEnabled() + assert not high_edit.isEnabled() + assert not select_widget._selection_panel.patch_basis_combo.isEnabled() + def test_annotation_input_filters_spool_to_matching_patches( self, select_widget, monkeypatch ): diff --git a/tests/test_widgets/test_waterfall.py b/tests/test_widgets/test_waterfall.py index 5ecf693..dae372b 100644 --- a/tests/test_widgets/test_waterfall.py +++ b/tests/test_widgets/test_waterfall.py @@ -662,6 +662,41 @@ def test_patch_none_clears_select_params_output( assert received == [None] + def test_saved_selection_without_patch_emits_select_params( + self, waterfall_widget, monkeypatch + ): + """Restored Waterfall selections should drive SelectParams-only links.""" + received = _capture_select_params_output(waterfall_widget, monkeypatch) + waterfall_widget.saved_selection_basis = "absolute" + waterfall_widget.saved_selection_ranges = [ + { + "dim": "distance", + "enabled": True, + "low": {"kind": "float", "value": 643.7278839133311}, + "high": {"kind": "float", "value": 793.7278839133311}, + }, + { + "dim": "time", + "enabled": True, + "low": {"kind": "float", "value": 0.013890560242722401}, + "high": {"kind": "float", "value": 0.03889056024272312}, + }, + ] + waterfall_widget._pending_saved_selection_restore = True + + waterfall_widget._emit_current_selection() + + params = received[-1] + assert isinstance(params, SelectParams) + assert params.kwargs["distance"] == pytest.approx( + (643.7278839133311, 793.7278839133311) + ) + assert params.kwargs["time"] == pytest.approx( + (0.013890560242722401, 0.03889056024272312) + ) + assert params.relative is False + assert params.samples is False + def test_range_selection_emits_select_params(self, waterfall_widget, monkeypatch): """Waterfall emits public patch.select params with selected patches.""" received = _capture_select_params_output(waterfall_widget, monkeypatch)