diff --git a/tests/test_cf_interface.py b/tests/test_cf_interface.py index 7226c57..dfeac6a 100644 --- a/tests/test_cf_interface.py +++ b/tests/test_cf_interface.py @@ -10,6 +10,7 @@ get_data_for_plotting, run_contour_plot, run_line_plot, + save_selected_field_data, ) import numpy as np @@ -172,6 +173,7 @@ class _FakeFigure: def __init__(self) -> None: self.text_calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] self.suptitle_calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + self.savefig_calls: list[str] = [] def text(self, *args: object, **kwargs: object) -> None: self.text_calls.append((args, kwargs)) @@ -179,14 +181,21 @@ def text(self, *args: object, **kwargs: object) -> None: def suptitle(self, *args: object, **kwargs: object) -> None: self.suptitle_calls.append((args, kwargs)) + def savefig(self, filename: str) -> None: + self.savefig_calls.append(filename) + class _FakePlt: def __init__(self) -> None: self.figure = _FakeFigure() + self.close_calls = 0 def gcf(self) -> _FakeFigure: return self.figure + def close(self, _fig: object) -> None: + self.close_calls += 1 + def test_run_contour_plot_applies_levels_annotations_and_save( monkeypatch: pytest.MonkeyPatch, @@ -210,11 +219,13 @@ def test_run_contour_plot_applies_levels_annotations_and_save( ) assert cfp.cscale_calls == [{"scale": "magma"}] - assert cfp.gopen_calls == [{"file": "/tmp/mock.png"}] + assert cfp.gopen_calls == [{"file": "cfplot.png", "user_plot": 1}] assert cfp.levs_calls == [{"manual": [-1.0, 0.0, 1.0]}] assert cfp.setvars_calls == [{"title_fontsize": 10.5, "viewer": None}] assert cfp.con_calls - assert cfp.gclose_calls == 1 + assert cfp.gclose_calls == 0 + assert plt_obj.figure.savefig_calls == ["/tmp/mock.png"] + assert plt_obj.close_calls == 1 assert plt_obj.figure.text_calls assert plt_obj.figure.text_calls[-1][1]["fontsize"] == 8.0 @@ -348,4 +359,18 @@ def render(self) -> None: assert captured["pfld"] is field_eg assert captured["options"] == {"filename": "/tmp/line.png", "title": "line"} assert captured["rendered"] is True + + +def test_save_selected_field_data_uses_cf_write(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[object, str]] = [] + monkeypatch.setattr( + cf_interface.cf, + "write", + lambda field, filename: calls.append((field, filename)), + ) + + field_obj = object() + save_selected_field_data(field_obj, "/tmp/out.nc") + + assert calls == [(field_obj, "/tmp/out.nc")] diff --git a/tests/test_contour.py b/tests/test_contour.py index 8455ece..856d96e 100644 --- a/tests/test_contour.py +++ b/tests/test_contour.py @@ -4,7 +4,7 @@ import numpy as np -from xconv2.cf_templates import contour_range_from_selection, plot_from_selection +from xconv2.cf_templates import contour_range_from_selection, plot_from_selection, save_data_from_selection import xconv2.xconv_cf_interface as cf_interface from xconv2.xconv_cf_interface import auto_contour_title, get_data_for_plotting, run_contour_plot from xconv2.ui.contour_options_controller import ContourOptionsController @@ -78,18 +78,26 @@ def gclose(self) -> None: @dataclass class _FakeFigure: text_calls: list[tuple[tuple[object, ...], dict[str, object]]] = field(default_factory=list) + savefig_calls: list[str] = field(default_factory=list) def text(self, *args: object, **kwargs: object) -> None: self.text_calls.append((args, kwargs)) + def savefig(self, filename: str) -> None: + self.savefig_calls.append(filename) + @dataclass class _FakePlt: figure: _FakeFigure = field(default_factory=_FakeFigure) + close_calls: int = 0 def gcf(self) -> _FakeFigure: return self.figure + def close(self, _fig: object) -> None: + self.close_calls += 1 + def _run_generated( @@ -113,6 +121,7 @@ def _run_generated( "cf": _FakeCF, "np": np, "get_data_for_plotting": get_data_for_plotting, + "save_selected_field_data": lambda _field, _path: None, "run_contour_plot": run_contour_plot, "auto_contour_title": auto_contour_title, "send_to_gui": lambda prefix, payload=None: messages.append((prefix, payload)), @@ -183,12 +192,15 @@ def test_plot_from_selection_contour_explicit_levels_and_save_file() -> None: fld = _FakeField() cfp = _FakeCFPlot() - messages = _run_generated(code, fld, cfp) + fake_plt = _FakePlt() + messages = _run_generated(code, fld, cfp, plt_obj=fake_plt) - assert cfp.gopen_calls == [{"file": output_file}] - assert cfp.gclose_calls == 1 + assert cfp.gopen_calls == [{"file": "cfplot.png", "user_plot": 1}] + assert cfp.gclose_calls == 0 assert cfp.cscale_calls == [{}] assert cfp.con_calls + assert fake_plt.figure.savefig_calls == [output_file] + assert fake_plt.close_calls == 1 assert ("STATUS:Saved plot to /tmp/mock-contour.png", None) in messages @@ -249,6 +261,32 @@ def test_plot_from_selection_lineplot_generates_worker_call() -> None: assert "lineplot_options" in code +def test_plot_from_selection_includes_data_save_when_requested() -> None: + code = plot_from_selection( + selections={"time": ("1", "2")}, + collapse_by_coord={}, + plot_kind="lineplot", + plot_options={"mode": "default"}, + save_data_path="/tmp/selection.nc", + ) + + assert "save_selected_field_data(pfld, save_data_path)" in code + assert "save_data_path = '/tmp/selection.nc'" in code + + +def test_save_data_from_selection_builds_data_only_worker_code() -> None: + code = save_data_from_selection( + selections={"time": ("1", "2")}, + collapse_by_coord={"time": "mean"}, + save_data_path="/tmp/data-only.nc", + ) + + assert "pfld = get_data_for_plotting" in code + assert "save_selected_field_data(pfld, save_data_path)" in code + assert "run_line_plot(" not in code + assert "run_contour_plot(" not in code + + def test_contour_property_text_normalization_compacts_linebreaks() -> None: text = "alpha\r\n beta\n\ngamma\rdelta" normalized = ContourOptionsController._normalize_property_cell_text(text) diff --git a/tests/test_coordinate_flow.py b/tests/test_coordinate_flow.py index ffefec1..1290065 100644 --- a/tests/test_coordinate_flow.py +++ b/tests/test_coordinate_flow.py @@ -6,7 +6,11 @@ from dataclasses import dataclass, field import types +from PySide6.QtWidgets import QStyle + from xconv2.main_window import CFVMain +from xconv2.core_window import CFVCore +from xconv2.ui.plot_view_controller import PlotViewController @dataclass @@ -73,9 +77,19 @@ def row(self, _item: object) -> int: class _DummyResetMain: _plot_request_in_flight: bool = True _plot_request_expects_image: bool = True + _selection_info_visible: bool = False loading_calls: list[bool] = field(default_factory=list) canvas_messages: list[str] = field(default_factory=list) status_messages: list[str] = field(default_factory=list) + panel_visible_calls: list[bool] = field(default_factory=list) + button_sync_calls: int = 0 + + def _set_selection_info_panel_visible(self, visible: bool) -> None: + self._selection_info_visible = visible + self.panel_visible_calls.append(visible) + + def _update_selection_info_toggle_button(self) -> None: + self.button_sync_calls += 1 def _set_plot_loading(self, is_loading: bool, message: str = "Rendering plot...") -> None: _ = message @@ -144,6 +158,114 @@ def _set_plot_loading(self, is_loading: bool, message: str = "Rendering plot..." self.loading_calls.append(is_loading) +@dataclass +class _DummyVisibilityPanel: + visible: bool = True + + def setVisible(self, visible: bool) -> None: + self.visible = visible + + def isVisible(self) -> bool: + return self.visible + + def isHidden(self) -> bool: + return not self.visible + + +@dataclass +class _DummyVisibilityButton: + icon: object | None = None + tooltip: str = "" + status_tip: str = "" + + def setIcon(self, icon: object) -> None: + self.icon = icon + + def setToolTip(self, tooltip: str) -> None: + self.tooltip = tooltip + + def setStatusTip(self, status_tip: str) -> None: + self.status_tip = status_tip + + +class _DummyStyle: + def standardIcon(self, icon_kind: QStyle.StandardPixmap) -> QStyle.StandardPixmap: + return icon_kind + + +@dataclass +class _DummyVisibilityMain: + plot_info_output: _DummyVisibilityPanel = field(default_factory=_DummyVisibilityPanel) + selection_info_toggle_button: _DummyVisibilityButton = field(default_factory=_DummyVisibilityButton) + _selection_info_visible: bool = True + _selection_info_expanded_from_width: int | None = None + width_value: int = 1000 + height_value: int = 700 + + def __post_init__(self) -> None: + self.plot_view_controller = types.SimpleNamespace( + adjust_window_width_for_info_panel=lambda _visible: None, + ) + + def style(self) -> _DummyStyle: + return _DummyStyle() + + def width(self) -> int: + return self.width_value + + def height(self) -> int: + return self.height_value + + def _update_selection_info_toggle_button(self) -> None: + CFVCore._update_selection_info_toggle_button(self) + + def _set_selection_info_panel_visible(self, visible: bool) -> None: + CFVCore._set_selection_info_panel_visible(self, visible) + + +@dataclass +class _DummyStartupVisibilityPanel: + hidden: bool = False + + def isVisible(self) -> bool: + # Simulate child widget before top-level show(): effectively not visible. + return False + + def isHidden(self) -> bool: + return self.hidden + + +@dataclass +class _DummyResetVisibilityMain: + _plot_request_in_flight: bool = True + _plot_request_expects_image: bool = True + _suppress_stale_error_status: bool = False + _selection_info_visible: bool = False + panel_visible_calls: list[bool] = field(default_factory=list) + button_sync_calls: int = 0 + loading_calls: list[bool] = field(default_factory=list) + canvas_messages: list[str] = field(default_factory=list) + status_messages: list[str] = field(default_factory=list) + + def _set_selection_info_panel_visible(self, visible: bool) -> None: + self._selection_info_visible = visible + self.panel_visible_calls.append(visible) + + def _update_selection_info_toggle_button(self) -> None: + self.button_sync_calls += 1 + + def _set_plot_loading(self, is_loading: bool, message: str = "Rendering plot...") -> None: + _ = message + self.loading_calls.append(is_loading) + + def _clear_plot_canvas(self, message: str = "Plot unavailable") -> None: + self.canvas_messages.append(message) + + def _show_status_message(self, message: str, is_error: bool = False) -> None: + _ = is_error + self.status_messages.append(message) + + def test_normalize_coordinate_metadata_filters_and_coerces() -> None: payload = [ ("time", ["1850-01-16", "1850-02-16"], "days since 1850-01-01 gregorian"), @@ -348,6 +470,111 @@ def test_handle_worker_output_ignores_stale_error_after_coord_message() -> None: assert dummy._suppress_stale_error_status is True +def test_toggle_selection_info_panel_updates_visibility_and_button_state() -> None: + dummy = _DummyVisibilityMain() + + CFVCore._toggle_selection_info_panel(dummy) + + assert dummy.plot_info_output.isVisible() is False + assert dummy._selection_info_visible is False + assert dummy.selection_info_toggle_button.icon == QStyle.SP_TitleBarUnshadeButton + assert dummy.selection_info_toggle_button.tooltip == "Show field details" + assert dummy.selection_info_toggle_button.status_tip == "Show field details" + + CFVCore._toggle_selection_info_panel(dummy) + + assert dummy.plot_info_output.isVisible() is True + assert dummy._selection_info_visible is True + assert dummy.selection_info_toggle_button.icon == QStyle.SP_TitleBarShadeButton + assert dummy.selection_info_toggle_button.tooltip == "Hide field details" + assert dummy.selection_info_toggle_button.status_tip == "Hide field details" + + +def test_toggle_selection_info_panel_stores_width_before_hiding() -> None: + dummy = _DummyVisibilityMain(width_value=1180) + + CFVCore._toggle_selection_info_panel(dummy) + + assert dummy._selection_info_expanded_from_width == 1180 + + +def test_compute_target_window_width_expands_when_plot_is_height_limited() -> None: + target_width = PlotViewController._compute_target_window_width( + current_window_width=1000, + current_plot_width=700, + current_plot_height=900, + pixmap_width=1200, + pixmap_height=800, + max_window_width=1600, + min_window_width=640, + ) + + assert target_width == 1600 + + +def test_compute_target_window_width_expands_without_hitting_screen_cap() -> None: + target_width = PlotViewController._compute_target_window_width( + current_window_width=1000, + current_plot_width=700, + current_plot_height=800, + pixmap_width=1000, + pixmap_height=800, + max_window_width=1600, + min_window_width=640, + ) + + assert target_width == 1300 + + +def test_compute_target_window_width_keeps_width_when_change_is_tiny() -> None: + target_width = PlotViewController._compute_target_window_width( + current_window_width=1000, + current_plot_width=700, + current_plot_height=474, + pixmap_width=1200, + pixmap_height=800, + max_window_width=1600, + min_window_width=640, + ) + + assert target_width == 1000 + + +def test_compute_target_window_width_shrinks_when_plot_is_too_wide_for_height() -> None: + target_width = PlotViewController._compute_target_window_width( + current_window_width=1500, + current_plot_width=1100, + current_plot_height=600, + pixmap_width=800, + pixmap_height=800, + max_window_width=1800, + min_window_width=640, + ) + + assert target_width == 1000 + + +def test_update_toggle_button_uses_hidden_state_not_effective_visibility() -> None: + dummy = _DummyVisibilityMain() + dummy.plot_info_output = _DummyStartupVisibilityPanel(hidden=False) + + CFVCore._update_selection_info_toggle_button(dummy) + + assert dummy._selection_info_visible is True + assert dummy.selection_info_toggle_button.icon == QStyle.SP_TitleBarShadeButton + assert dummy.selection_info_toggle_button.tooltip == "Hide field details" + + +def test_reset_ui_for_new_field_selection_reveals_details_panel() -> None: + dummy = _DummyResetVisibilityMain() + + CFVMain._reset_ui_for_new_field_selection(dummy) + + assert dummy._selection_info_visible is True + assert dummy.panel_visible_calls[-1] is True + assert dummy.button_sync_calls == 1 + + def test_handle_worker_output_remote_status_routes_message() -> None: payload = { "phase": "preparing", diff --git a/xconv2/cf_templates.py b/xconv2/cf_templates.py index 96bea52..b867623 100644 --- a/xconv2/cf_templates.py +++ b/xconv2/cf_templates.py @@ -56,6 +56,7 @@ def plot_from_selection( collapse_by_coord: dict[str, str], plot_kind: str, plot_options: dict[str, object] | None = None, + save_data_path: str | None = None, ) -> str: """Generate worker code for plotting based on GUI selections. @@ -72,7 +73,21 @@ def plot_from_selection( elif plot_kind == "contour": plot_code = contour(options=plot_options) - return "\n".join([prep_code, plot_code]) + data_save_code = _save_data_code(save_data_path) if save_data_path else "" + parts = [prep_code, plot_code] + if data_save_code: + parts.append(data_save_code) + return "\n".join(parts) + + +def save_data_from_selection( + selections: dict[str, tuple[object, object]], + collapse_by_coord: dict[str, str], + save_data_path: str, +) -> str: + """Generate worker code that saves selected data without rendering a plot.""" + prep_code = _pfld_from_selection_code(selections, collapse_by_coord) + return "\n".join([prep_code, _save_data_code(save_data_path)]) def contour_range_from_selection( @@ -154,3 +169,15 @@ def lineplot(options: dict[str, object] | None) -> str: ).lstrip() return payload_code + +def _save_data_code(save_data_path: str) -> str: + """Generate worker code that persists selected data via cf.write.""" + payload_code = textwrap.dedent( + f""" + save_data_path = {save_data_path!r} + save_selected_field_data(pfld, save_data_path) + send_to_gui(f"STATUS:Saved data to {{save_data_path}}") #omit4save + """ + ).lstrip() + return payload_code + diff --git a/xconv2/core_window.py b/xconv2/core_window.py index 4b81fd4..db7831f 100644 --- a/xconv2/core_window.py +++ b/xconv2/core_window.py @@ -43,6 +43,7 @@ QSpinBox, QStyle, QSystemTrayIcon, + QToolButton, QVBoxLayout, QWidget, ) @@ -108,6 +109,9 @@ def __init__(self) -> None: self._plot_pixmap_original: QPixmap | None = None self.current_selection_info_text = "No selection info available." self.slider_scroll_area: QScrollArea | None = None + self.selection_info_toggle_button: QToolButton | None = None + self._selection_info_visible = True + self._selection_info_expanded_from_width: int | None = None self.setup_ui() self._setup_tray_icon() @@ -183,6 +187,7 @@ def setup_ui(self) -> None: self.plot_area = self._create_plot_area() layout.addWidget(left_panel) layout.addWidget(self.plot_area, stretch=1) + self._update_selection_info_toggle_button() self._setup_menu_bar() self._setup_status_bar() @@ -807,9 +812,13 @@ def _create_selection_frame(self) -> QGroupBox: reset_button = QPushButton("Reset all sliders") reset_button.setToolTip("Reset all range sliders to full coordinate extent") reset_button.clicked.connect(self._reset_all_sliders) + self.selection_info_toggle_button = QToolButton() + self.selection_info_toggle_button.setAutoRaise(True) + self.selection_info_toggle_button.clicked.connect(self._toggle_selection_info_panel) controls_row.addWidget(properties_button) controls_row.addWidget(reset_button) controls_row.addStretch(1) + controls_row.addWidget(self.selection_info_toggle_button) layout.addLayout(controls_row) layout.addWidget(self._create_slider_scroll_area()) @@ -855,6 +864,41 @@ def _show_selection_properties(self) -> None: """Show properties for the currently selected field.""" self.field_metadata_controller.show_selection_properties() + def _toggle_selection_info_panel(self) -> None: + """Show or hide the field-detail panel above the plot.""" + if self._selection_info_visible: + self._selection_info_expanded_from_width = self.width() + self._set_selection_info_panel_visible(not self._selection_info_visible) + self._update_selection_info_toggle_button() + self.plot_view_controller.adjust_window_width_for_info_panel(self._selection_info_visible) + + def _set_selection_info_panel_visible(self, visible: bool) -> None: + """Set the details panel visibility without toggling width behavior.""" + self._selection_info_visible = visible + if hasattr(self, "plot_info_output") and self.plot_info_output is not None: + self.plot_info_output.setVisible(visible) + + def _update_selection_info_toggle_button(self) -> None: + """Sync the details-toggle button icon and tooltip with panel visibility.""" + button = self.selection_info_toggle_button + if button is None: + return + + if hasattr(self, "plot_info_output") and self.plot_info_output is not None: + # Use explicit hidden state: isVisible() is false before the top-level window is shown. + self._selection_info_visible = not self.plot_info_output.isHidden() + + if self._selection_info_visible: + icon = self.style().standardIcon(QStyle.SP_TitleBarShadeButton) + tooltip = "Hide field details" + else: + icon = self.style().standardIcon(QStyle.SP_TitleBarUnshadeButton) + tooltip = "Show field details" + + button.setIcon(icon) + button.setToolTip(tooltip) + button.setStatusTip(tooltip) + def _save_properties_to_csv( self, properties: dict[object, object], @@ -1208,6 +1252,24 @@ def _request_plot_save(self, file_path: str) -> None: """Hook for worker-backed implementations to save rendered plot output.""" logger.debug("Requested plot save to: %s", file_path) + def _request_plot_data_save(self, file_path: str) -> None: + """Hook for worker-backed implementations to save selected field data.""" + logger.debug("Requested data save to: %s", file_path) + + def _request_plot_save_all( + self, + save_code_path: str, + save_plot_path: str, + save_data_path: str, + ) -> None: + """Hook for worker-backed implementations to save code, plot, and data.""" + logger.debug( + "Requested save-all code=%s plot=%s data=%s", + save_code_path, + save_plot_path, + save_data_path, + ) + def _quit_application(self) -> None: """Quit the whole application, even when modal dialogs are open.""" logger.info("Quit requested from UI") diff --git a/xconv2/main_window.py b/xconv2/main_window.py index 77318d7..81ab959 100644 --- a/xconv2/main_window.py +++ b/xconv2/main_window.py @@ -29,6 +29,7 @@ coordinate_list, field_list, plot_from_selection, + save_data_from_selection, ) from .core_window import CFVCore from .ui.dialogs import OpenURIDialog, RemoteConfigurationDialog, RemoteOpenDialog @@ -94,6 +95,8 @@ def _reset_ui_for_new_field_selection(self) -> None: self._plot_request_in_flight = False self._plot_request_expects_image = False self._suppress_stale_error_status = True + self._set_selection_info_panel_visible(True) + self._update_selection_info_toggle_button() self._set_plot_loading(False) self._clear_plot_canvas("Waiting for data...") self._show_status_message("Task Complete") @@ -1286,15 +1289,51 @@ def _normalize_coordinate_metadata(self, payload: object) -> dict[str, dict[str, def _request_plot_update(self) -> None: """Request a new plot using current slider and collapse selections.""" - self._request_plot_task(save_code_path=None, save_plot_path=None) + self._request_plot_task( + save_code_path=None, + save_plot_path=None, + save_data_path=None, + ) def _request_plot_code_save(self, file_path: str) -> None: """Request plotting and ask the worker to save the generated code to a file.""" - self._request_plot_task(save_code_path=file_path, save_plot_path=None) + self._request_plot_task( + save_code_path=file_path, + save_plot_path=None, + save_data_path=None, + ) def _request_plot_save(self, file_path: str) -> None: """Request plotting directly to a file output path.""" - self._request_plot_task(save_code_path=None, save_plot_path=file_path) + self._request_plot_task( + save_code_path=None, + save_plot_path=file_path, + save_data_path=None, + emit_image_override=False, + ) + + def _request_plot_data_save(self, file_path: str) -> None: + """Request saving the currently selected/collapsed field data.""" + self._request_plot_task( + save_code_path=None, + save_plot_path=None, + save_data_path=file_path, + emit_image_override=False, + ) + + def _request_plot_save_all( + self, + save_code_path: str, + save_plot_path: str, + save_data_path: str, + ) -> None: + """Request saving plot image, data, and generated script in one action.""" + self._request_plot_task( + save_code_path=save_code_path, + save_plot_path=save_plot_path, + save_data_path=save_data_path, + emit_image_override=False, + ) def _request_plot_options(self) -> None: """Fetch plot-type specific option context from worker.""" @@ -1372,8 +1411,14 @@ def _build_plot_context(self) -> tuple[dict[str, tuple[object, object]], dict[st return selections, collapse_by_coord, plot_kind - def _request_plot_task(self, save_code_path: str | None, save_plot_path: str | None) -> None: - """Build and send a plot task with optional code-save and plot-save paths.""" + def _request_plot_task( + self, + save_code_path: str | None, + save_plot_path: str | None, + save_data_path: str | None, + emit_image_override: bool | None = None, + ) -> None: + """Build and send a plot/data task with optional save targets.""" context = self._build_plot_context() if context is None: logger.info("PLOT_DIAG gui_plot_skip reason=no_controls") @@ -1389,29 +1434,50 @@ def _request_plot_task(self, save_code_path: str | None, save_plot_path: str | N ) return - plot_options = dict(self.plot_options_by_kind.get(plot_kind, {})) - if plot_kind == "contour": - plot_options.setdefault("contour_title_fontsize", self._contour_title_fontsize()) - plot_options.setdefault("page_title_fontsize", self._page_title_fontsize()) - plot_options.setdefault("annotation_fontsize", self._annotation_fontsize()) - - if save_plot_path: - plot_options["filename"] = str(Path(save_plot_path).expanduser()) - elif not plot_options: - plot_options = None - - try: - cmd = plot_from_selection(selections, collapse_by_coord, plot_kind, plot_options) - except (ValueError, NotImplementedError) as exc: - self._show_status_message(f"Plot request unavailable: {exc}", is_error=True) - logger.warning("Plot template unavailable for kind=%s: %s", plot_kind, exc) - return - save_target = None if save_code_path: save_target = str(Path(save_code_path).expanduser()) - emit_image = save_plot_path is None + save_plot_target = str(Path(save_plot_path).expanduser()) if save_plot_path else None + save_data_target = str(Path(save_data_path).expanduser()) if save_data_path else None + + if save_data_target and not save_plot_target: + if save_code_path: + self._show_status_message( + "Save Code + Save Data requires a plot target. Use Save All.", + is_error=True, + ) + return + cmd = save_data_from_selection(selections, collapse_by_coord, save_data_target) + else: + plot_options = dict(self.plot_options_by_kind.get(plot_kind, {})) + if plot_kind == "contour": + plot_options.setdefault("contour_title_fontsize", self._contour_title_fontsize()) + plot_options.setdefault("page_title_fontsize", self._page_title_fontsize()) + plot_options.setdefault("annotation_fontsize", self._annotation_fontsize()) + + if save_plot_target: + plot_options["filename"] = save_plot_target + elif not plot_options: + plot_options = None + + try: + cmd = plot_from_selection( + selections, + collapse_by_coord, + plot_kind, + plot_options, + save_data_path=save_data_target, + ) + except (ValueError, NotImplementedError) as exc: + self._show_status_message(f"Plot request unavailable: {exc}", is_error=True) + logger.warning("Plot template unavailable for kind=%s: %s", plot_kind, exc) + return + + if emit_image_override is not None: + emit_image = emit_image_override + else: + emit_image = save_plot_target is None and save_data_target is None logger.debug( "Requesting plot update kind=%s coords=%d collapses=%d save_code=%s save_plot=%s", @@ -1419,18 +1485,24 @@ def _request_plot_task(self, save_code_path: str | None, save_plot_path: str | N len(selections), len(collapse_by_coord), bool(save_target), - bool(save_plot_path), + bool(save_plot_target), ) logger.info( "PLOT_DIAG gui_plot_request pid=%s worker_pid=%s kind=%s emit_image=%s", os.getpid(), self.worker.processId(), plot_kind, - save_plot_path is None, + emit_image, ) - if save_plot_path: + if save_target and save_plot_target and save_data_target: + loading_message = "Saving plot, data, and code..." + elif save_plot_target and save_data_target: + loading_message = "Rendering and saving plot/data..." + elif save_plot_target: loading_message = "Rendering and saving plot..." + elif save_data_target: + loading_message = "Saving selected data..." elif save_code_path: loading_message = "Rendering plot and saving code..." else: diff --git a/xconv2/ui/plot_view_controller.py b/xconv2/ui/plot_view_controller.py index 9a13025..4cf5dc0 100644 --- a/xconv2/ui/plot_view_controller.py +++ b/xconv2/ui/plot_view_controller.py @@ -10,6 +10,7 @@ from PySide6.QtWidgets import ( QApplication, QComboBox, + QFrame, QFileDialog, QHBoxLayout, QLabel, @@ -91,6 +92,11 @@ def paintEvent(self, event) -> None: # type: ignore[override] class PlotViewController: """Manage plot area widgets, pixmap rendering, and save actions.""" + SAVE_MODE_PLOT = "plot" + SAVE_MODE_DATA = "data" + SAVE_MODE_CODE = "code" + SAVE_MODE_ALL = "all" + def __init__(self, host: "CFVCore") -> None: self.host = host @@ -164,19 +170,60 @@ def create_plot_area(self) -> QWidget: self.host.options_button = QPushButton("Options") self.host.options_button.setEnabled(False) self.host.options_button.clicked.connect(self.on_options_button_clicked) - self.host.save_code_button = QPushButton("Save Code...") - self.host.save_code_button.setEnabled(False) - self.host.save_code_button.clicked.connect(self.on_save_code_button_clicked) - self.host.save_plot_button = QPushButton("Save Plot...") - self.host.save_plot_button.setEnabled(False) - self.host.save_plot_button.clicked.connect(self.on_save_plot_button_clicked) - - summary_row.addWidget(self.host.plot_summary_label, 1) - summary_row.addWidget(self.host.plot_type_combo) - summary_row.addWidget(self.host.plot_button) - summary_row.addWidget(self.host.options_button) - summary_row.addWidget(self.host.save_code_button) - summary_row.addWidget(self.host.save_plot_button) + self.host.save_target_combo = QComboBox() + self.host.save_target_combo.setMinimumWidth(110) + self.host.save_target_combo.addItem("Plot", self.SAVE_MODE_PLOT) + self.host.save_target_combo.addItem("Data", self.SAVE_MODE_DATA) + self.host.save_target_combo.addItem("Code", self.SAVE_MODE_CODE) + self.host.save_target_combo.addItem("All", self.SAVE_MODE_ALL) + self.host.save_target_combo.setEnabled(False) + self.host.save_go_button = QPushButton("Export") + self.host.save_go_button.setEnabled(False) + self.host.save_go_button.clicked.connect(self.on_save_go_clicked) + + combo_height = max( + self.host.plot_type_combo.sizeHint().height(), + self.host.save_target_combo.sizeHint().height(), + ) + for button in (self.host.plot_button, self.host.options_button, self.host.save_go_button): + button.setMinimumHeight(combo_height) + + plot_controls_group = QFrame() + plot_controls_group.setObjectName("plot_controls_group") + plot_controls_group.setFrameShape(QFrame.StyledPanel) + plot_controls_group.setFrameShadow(QFrame.Plain) + plot_controls_group.setStyleSheet( + "QFrame#plot_controls_group {" + " border: 1px solid palette(mid);" + " border-radius: 4px;" + "}" + ) + plot_controls_layout = QHBoxLayout(plot_controls_group) + plot_controls_layout.setContentsMargins(6, 2, 6, 2) + plot_controls_layout.setSpacing(6) + plot_controls_layout.addWidget(self.host.plot_type_combo) + plot_controls_layout.addWidget(self.host.plot_button) + plot_controls_layout.addWidget(self.host.options_button) + + export_controls_group = QFrame() + export_controls_group.setObjectName("export_controls_group") + export_controls_group.setFrameShape(QFrame.StyledPanel) + export_controls_group.setFrameShadow(QFrame.Plain) + export_controls_group.setStyleSheet( + "QFrame#export_controls_group {" + " border: 1px solid palette(mid);" + " border-radius: 4px;" + "}" + ) + export_controls_layout = QHBoxLayout(export_controls_group) + export_controls_layout.setContentsMargins(6, 2, 6, 2) + export_controls_layout.setSpacing(6) + export_controls_layout.addWidget(self.host.save_go_button) + export_controls_layout.addWidget(self.host.save_target_combo) + + summary_row.addWidget(self.host.plot_summary_label, 1, Qt.AlignVCenter) + summary_row.addWidget(plot_controls_group, 0, Qt.AlignVCenter) + summary_row.addWidget(export_controls_group, 0, Qt.AlignVCenter) layout.addWidget(self.host.plot_info_output) layout.addWidget(plot_stack_container, 1) @@ -274,39 +321,172 @@ def set_plot_image(self, png_bytes: bytes) -> None: self.host._plot_pixmap_original = pixmap self.set_plot_loading(False) - self.fit_window_to_plot_aspect() + self.refresh_plot_pixmap() + QTimer.singleShot(0, self.fit_window_to_plot_aspect) + + def adjust_window_width_for_info_panel(self, info_panel_visible: bool) -> None: + """Re-fit window geometry to current plot aspect after layout toggles.""" + QTimer.singleShot(0, lambda: self._apply_window_width_for_info_panel(info_panel_visible)) + + @staticmethod + def _compute_target_window_width( + current_window_width: int, + current_plot_width: int, + current_plot_height: int, + pixmap_width: int, + pixmap_height: int, + max_window_width: int, + min_window_width: int, + ) -> int: + """Return a window width target that reduces side/top-bottom letterboxing.""" + if min( + current_window_width, + current_plot_width, + current_plot_height, + pixmap_width, + pixmap_height, + max_window_width, + min_window_width, + ) <= 0: + return current_window_width + + desired_plot_width = int(round(current_plot_height * (pixmap_width / pixmap_height))) + width_delta = desired_plot_width - current_plot_width + if abs(width_delta) <= 12: + return current_window_width + + return max( + min_window_width, + min(current_window_width + width_delta, max_window_width), + ) + + def _apply_window_width_for_info_panel(self, info_panel_visible: bool) -> None: + """Apply width expansion when hiding details and restore width when showing them.""" + if info_panel_visible: + restore_width = getattr(self.host, "_selection_info_expanded_from_width", None) + if restore_width is not None and restore_width != self.host.width(): + self.host.resize(max(self.host.minimumWidth(), restore_width), self.host.height()) + + pixmap = getattr(self.host, "_plot_pixmap_original", None) + if pixmap is not None: + current_plot_width = max(self.host.plot_frame.width(), 1) + current_plot_height = max(self.host.plot_frame.height(), 1) + + screen = self.host.screen() or QApplication.primaryScreen() + if screen is not None: + available_width = screen.availableGeometry().width() + min_width = max(self.host.minimumWidth(), 640) + base_max_width = max(min_width, int(available_width * 0.95)) + if restore_width is not None: + max_width = max(min_width, min(base_max_width, restore_width)) + else: + max_width = base_max_width + + target_width = self._compute_target_window_width( + current_window_width=self.host.width(), + current_plot_width=current_plot_width, + current_plot_height=current_plot_height, + pixmap_width=pixmap.width(), + pixmap_height=pixmap.height(), + max_window_width=max_width, + min_window_width=min_width, + ) + if target_width != self.host.width(): + self.host.resize(target_width, self.host.height()) + + self.host._selection_info_expanded_from_width = None + self.refresh_plot_pixmap() + return + + pixmap = getattr(self.host, "_plot_pixmap_original", None) + if pixmap is None: + self.refresh_plot_pixmap() + return + + current_plot_width = max(self.host.plot_frame.width(), 1) + current_plot_height = max(self.host.plot_frame.height(), 1) + + screen = self.host.screen() or QApplication.primaryScreen() + if screen is None: + self.refresh_plot_pixmap() + return + + available_width = screen.availableGeometry().width() + min_width = max(self.host.minimumWidth(), 640) + max_width = max(min_width, int(available_width * 0.95)) + target_width = self._compute_target_window_width( + current_window_width=self.host.width(), + current_plot_width=current_plot_width, + current_plot_height=current_plot_height, + pixmap_width=pixmap.width(), + pixmap_height=pixmap.height(), + max_window_width=max_width, + min_window_width=min_width, + ) + + if target_width != self.host.width(): + self.host.resize(target_width, self.host.height()) + self.refresh_plot_pixmap() def fit_window_to_plot_aspect(self) -> None: - """Nudge window height to match plot aspect ratio without exceeding screen bounds.""" + """Resize window to keep plot viewport aligned with the image aspect ratio.""" if self.host._plot_pixmap_original is None: return - plot_height = self.host._plot_pixmap_original.height() - plot_width = self.host._plot_pixmap_original.width() - if plot_height <= 0 or plot_width <= 0: + pixmap_height = self.host._plot_pixmap_original.height() + pixmap_width = self.host._plot_pixmap_original.width() + if pixmap_height <= 0 or pixmap_width <= 0: return - aspect_ratio = plot_width / plot_height current_plot_width = max(self.host.plot_frame.width(), 1) - desired_plot_height = max(1, int(current_plot_width / aspect_ratio)) current_plot_height = max(self.host.plot_frame.height(), 1) + aspect_ratio = pixmap_width / pixmap_height + desired_plot_width = max(1, int(round(current_plot_height * aspect_ratio))) + desired_plot_height = max(1, int(round(current_plot_width / aspect_ratio))) + + width_delta = desired_plot_width - current_plot_width height_delta = desired_plot_height - current_plot_height - if abs(height_delta) < 12: + if abs(width_delta) < 10 and abs(height_delta) < 10: return + chrome_width = max(0, self.host.width() - current_plot_width) + chrome_height = max(0, self.host.height() - current_plot_height) + target_width = self.host.width() + width_delta + target_height = self.host.height() + height_delta + screen = self.host.screen() or QApplication.primaryScreen() if screen is None: return + available_width = screen.availableGeometry().width() available_height = screen.availableGeometry().height() + min_width = max(self.host.minimumWidth(), 640) min_height = max(self.host.minimumHeight(), 420) + max_width = max(min_width, int(available_width * 0.95)) max_height = max(min_height, int(available_height * 0.9)) - target_height = max(min_height, min(self.host.height() + height_delta, max_height)) + target_width = max(min_width, min(target_width, max_width)) + target_height = max(min_height, min(target_height, max_height)) + + # If one axis was clamped by screen bounds, recompute the other axis + # from the available plot viewport so aspect fitting remains consistent. + if target_width != self.host.width() or target_height != self.host.height(): + fitted_plot_width = max(1, target_width - chrome_width) + fitted_plot_height = max(1, target_height - chrome_height) + fitted_ratio = fitted_plot_width / fitted_plot_height + + if fitted_ratio > aspect_ratio: + fitted_plot_width = max(1, int(round(fitted_plot_height * aspect_ratio))) + target_width = max(min_width, min(chrome_width + fitted_plot_width, max_width)) + else: + fitted_plot_height = max(1, int(round(fitted_plot_width / aspect_ratio))) + target_height = max(min_height, min(chrome_height + fitted_plot_height, max_height)) + + if target_width != self.host.width() or target_height != self.host.height(): + self.host.resize(target_width, target_height) - if target_height != self.host.height(): - self.host.resize(self.host.width(), target_height) + self.refresh_plot_pixmap() def refresh_plot_pixmap(self) -> None: """Scale current plot pixmap to fit the visible plot frame.""" @@ -325,9 +505,24 @@ def refresh_plot_pixmap(self) -> None: self.host.plot_frame.setPixmap(scaled) self.host.plot_frame.setText("") + def on_save_go_clicked(self) -> None: + """Run save action for the currently selected save target mode.""" + if not getattr(self.host, "save_go_button", None) or not self.host.save_go_button.isEnabled(): + return + + mode = self.host.save_target_combo.currentData() + if mode == self.SAVE_MODE_CODE: + self.on_save_code_button_clicked() + elif mode == self.SAVE_MODE_DATA: + self.on_save_data_button_clicked() + elif mode == self.SAVE_MODE_ALL: + self.on_save_all_button_clicked() + else: + self.on_save_plot_button_clicked() + def on_save_code_button_clicked(self) -> None: """Prompt for destination file and request worker-side plot code save.""" - if not getattr(self.host, "save_code_button", None) or not self.host.save_code_button.isEnabled(): + if not getattr(self.host, "save_go_button", None) or not self.host.save_go_button.isEnabled(): return default_path = self.host._default_save_path("last_save_code_dir", "cfview_plot_code.py") @@ -346,9 +541,31 @@ def on_save_code_button_clicked(self) -> None: self.host._remember_last_save_dir("last_save_code_dir", file_path) self.host._request_plot_code_save(file_path) + def on_save_data_button_clicked(self) -> None: + """Prompt for destination file and request worker-side selected-data save.""" + if not getattr(self.host, "save_go_button", None) or not self.host.save_go_button.isEnabled(): + return + + default_stem = self.host._default_plot_filename() + default_path = self.host._default_save_path("last_save_data_dir", f"{default_stem}.nc") + file_path, _ = QFileDialog.getSaveFileName( + self.host, + "Save Plot Data", + default_path, + "NetCDF files (*.nc);;All files (*)", + ) + if not file_path: + return + + if not Path(file_path).suffix: + file_path += ".nc" + + self.host._remember_last_save_dir("last_save_data_dir", file_path) + self.host._request_plot_data_save(file_path) + def on_save_plot_button_clicked(self) -> None: """Prompt for destination image file and request worker-side plot save.""" - if not getattr(self.host, "save_plot_button", None) or not self.host.save_plot_button.isEnabled(): + if not getattr(self.host, "save_go_button", None) or not self.host.save_go_button.isEnabled(): return format_filters = { @@ -388,3 +605,33 @@ def on_save_plot_button_clicked(self) -> None: self.host._remember_last_save_dir("last_save_plot_dir", file_path) self.host._request_plot_save(file_path) + + def on_save_all_button_clicked(self) -> None: + """Prompt for a stem path and save plot/data/code outputs in one action.""" + if not getattr(self.host, "save_go_button", None) or not self.host.save_go_button.isEnabled(): + return + + default_stem = self.host._default_plot_filename() + default_path = self.host._default_save_path("last_save_plot_dir", default_stem) + stem_path, _ = QFileDialog.getSaveFileName( + self.host, + "Save Plot/Data/Code (Choose stem)", + default_path, + "All files (*)", + ) + if not stem_path: + return + + stem = Path(stem_path).expanduser() + if stem.suffix.lower() in {".png", ".svg", ".pdf", ".py", ".nc"}: + stem = stem.with_suffix("") + + plot_ext = self.host._default_plot_output_format() + save_plot_path = str(stem.with_suffix(f".{plot_ext}")) + save_data_path = str(stem.with_suffix(".nc")) + save_code_path = str(stem.with_suffix(".py")) + + self.host._remember_last_save_dir("last_save_plot_dir", save_plot_path) + self.host._remember_last_save_dir("last_save_data_dir", save_data_path) + self.host._remember_last_save_dir("last_save_code_dir", save_code_path) + self.host._request_plot_save_all(save_code_path, save_plot_path, save_data_path) diff --git a/xconv2/ui/selection_controller.py b/xconv2/ui/selection_controller.py index cd9df2f..497b19c 100644 --- a/xconv2/ui/selection_controller.py +++ b/xconv2/ui/selection_controller.py @@ -29,6 +29,21 @@ class SelectionController: def __init__(self, host: "CFVCore") -> None: self.host = host + def _set_save_controls_enabled(self, enabled: bool) -> None: + """Enable or disable save mode selector and save action button.""" + save_combo = getattr(self.host, "save_target_combo", None) + save_button = getattr(self.host, "save_go_button", None) + legacy_save_code_button = getattr(self.host, "save_code_button", None) + legacy_save_plot_button = getattr(self.host, "save_plot_button", None) + if save_combo is not None: + save_combo.setEnabled(enabled) + if save_button is not None: + save_button.setEnabled(enabled) + if legacy_save_code_button is not None: + legacy_save_code_button.setEnabled(enabled) + if legacy_save_plot_button is not None: + legacy_save_plot_button.setEnabled(enabled) + def reset_all_sliders(self) -> None: """Reset all slider ranges to full extent and refresh summary state.""" self.host.selected_collapse_methods.clear() @@ -340,8 +355,7 @@ def refresh_plot_summary(self) -> None: self.host.plot_view_controller.set_plot_type_options([], None) self.host.plot_button.setEnabled(False) self.host.options_button.setEnabled(False) - self.host.save_code_button.setEnabled(False) - self.host.save_plot_button.setEnabled(False) + self._set_save_controls_enabled(False) return dims: list[int] = [] @@ -386,32 +400,28 @@ def refresh_plot_summary(self) -> None: self.host.plot_view_controller.set_plot_type_options(available_plot_kinds, selected_kind) if varying_dims == 0: - self.host.plot_summary_label.setText(f"{dims_text} | Total collapse, plot not possible") + self.host.plot_summary_label.setText(f"{dims_text} \nTotal collapse, plot not possible") self.host.plot_button.setEnabled(False) self.host.options_button.setEnabled(False) - self.host.save_code_button.setEnabled(False) - self.host.save_plot_button.setEnabled(False) + self._set_save_controls_enabled(False) elif varying_dims == 1: self.host.plot_summary_label.setText( - f"{dims_text} | Plot Type: {selected_kind.title() if selected_kind else 'N/A'}" + f"{dims_text} \nPlot Type: {selected_kind.title() if selected_kind else 'N/A'}" ) self.host.plot_button.setEnabled(True) self.host.options_button.setEnabled(selected_kind in {"contour", "lineplot"}) - self.host.save_code_button.setEnabled(True) - self.host.save_plot_button.setEnabled(True) + self._set_save_controls_enabled(True) elif varying_dims == 2: self.host.plot_summary_label.setText( - f"{dims_text} | Plot Type: {selected_kind.title() if selected_kind else 'N/A'}" + f"{dims_text} \nPlot Type: {selected_kind.title() if selected_kind else 'N/A'}" ) self.host.plot_button.setEnabled(True) self.host.options_button.setEnabled(selected_kind in {"contour", "lineplot"}) - self.host.save_code_button.setEnabled(True) - self.host.save_plot_button.setEnabled(True) + self._set_save_controls_enabled(True) else: self.host.plot_summary_label.setText( - f"{dims_text} | Need to reduce to 1D or 2D before plotting" + f"{dims_text} \nNeed to reduce to 1D or 2D before plotting" ) self.host.plot_button.setEnabled(False) self.host.options_button.setEnabled(False) - self.host.save_code_button.setEnabled(False) - self.host.save_plot_button.setEnabled(False) + self._set_save_controls_enabled(False) diff --git a/xconv2/ui/settings_store.py b/xconv2/ui/settings_store.py index a6d3423..5d97abc 100644 --- a/xconv2/ui/settings_store.py +++ b/xconv2/ui/settings_store.py @@ -64,6 +64,7 @@ def default_settings(self) -> dict[str, object]: "default_plot_filename": "xconv_{timestamp}", "default_plot_format": "png", "last_save_code_dir": str(Path.home()), + "last_save_data_dir": str(Path.home()), "last_save_plot_dir": str(Path.home()), } @@ -115,7 +116,7 @@ def load(self) -> dict[str, object]: if migrated: settings["recent_files"] = migrated - for key in ("last_save_code_dir", "last_save_plot_dir"): + for key in ("last_save_code_dir", "last_save_data_dir", "last_save_plot_dir"): value = settings.get(key) if not isinstance(value, str) or not value.strip(): settings[key] = str(Path.home()) diff --git a/xconv2/xconv_cf_interface.py b/xconv2/xconv_cf_interface.py index a310f64..4c10117 100644 --- a/xconv2/xconv_cf_interface.py +++ b/xconv2/xconv_cf_interface.py @@ -23,6 +23,7 @@ "field_info", "coordinate_info", "get_data_for_plotting", + "save_selected_field_data", "annotation_text", "estimate_layout_padding", "apply_vertical_padding", @@ -184,6 +185,10 @@ def _parse_bound(value: object) -> object: return pfld +def save_selected_field_data(field: object, filename: str) -> None: + """Persist selected field data to disk using cf.write.""" + cf.write(field, filename) + def run_contour_plot( pfld: object, options: dict[str, object] | None, @@ -330,12 +335,9 @@ def _run_contour_prepass() -> None: top_padding += page_margin_top bottom_padding += page_margin_bottom - # Force cf-plot into embedded mode for in-memory rendering. Without this, - # cfp.con() may implicitly call gclose() and trigger an external viewer. - if filename is None: - cfp.gopen(user_plot=1) - else: - cfp.gopen(file=filename) + # Force cf-plot into embedded mode for worker rendering. Using cf-plot's + # file mode can trigger an external viewer command on some platforms. + cfp.gopen(user_plot=1) _apply_levels() @@ -366,19 +368,8 @@ def _run_contour_prepass() -> None: ) if filename is not None: - cfp.gclose() - output_path = str(filename) - output_exists = False - try: - with open(output_path, "rb"): - output_exists = True - except OSError: - output_exists = False - - # Some cf-plot backends can silently skip writing the requested file. - # Fall back to matplotlib savefig to ensure the user-selected file is created. - if not output_exists and hasattr(plt, "savefig"): - plt.savefig(output_path) + mycanvas.savefig(str(filename)) + plt.close(mycanvas) def run_line_plot( pfld: object,