Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion tests/test_cf_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ def __call__(self) -> str:
class _MockField:
shape = (2, 3)

def __str__(self) -> str:
return "mock-field-summary"

def identity(self) -> str:
return "air_temperature"

Expand Down Expand Up @@ -55,7 +58,7 @@ def test_field_info_returns_serialized_rows() -> None:
parts = payload[0].split("\x1f", 2)
assert len(parts) == 3
assert parts[0].startswith("air_temperature")
assert "latitude" in parts[1]
assert parts[1] == "mock-field-summary"
assert "units" in parts[2]


Expand Down
Binary file added xconv2/assets/cf-logo-box.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions xconv2/assets/cf-logo-box.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
280 changes: 227 additions & 53 deletions xconv2/core_window.py

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions xconv2/ui/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
"""UI support modules for the xconv2 main window."""

from .dialogs import InputDialogCustom, OpenGlobDialog

__all__ = ["InputDialogCustom", "OpenGlobDialog"]
154 changes: 154 additions & 0 deletions xconv2/ui/dialogs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from __future__ import annotations

from pathlib import Path

from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QFileDialog,
QComboBox,
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)


class InputDialogCustom(QDialog):
"""Reusable item chooser with optional rich-text documentation below input."""

def __init__(
self,
parent: QWidget | None,
title: str,
label: str,
items: list[str],
current_index: int,
editable: bool,
flags: Qt.WindowType,
input_method_hints: Qt.InputMethodHint,
doc_text: str,
) -> None:
super().__init__(parent, flags)
self.setWindowTitle(title)

layout = QVBoxLayout(self)

prompt = QLabel(label)
layout.addWidget(prompt)

self.item_combo = QComboBox()
self.item_combo.addItems(items)
self.item_combo.setEditable(editable)
self.item_combo.setInputMethodHints(input_method_hints)
if items:
self.item_combo.setCurrentIndex(max(0, min(current_index, len(items) - 1)))
layout.addWidget(self.item_combo)

if doc_text:
doc_label = QLabel(doc_text)
doc_label.setTextFormat(Qt.RichText)
doc_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
doc_label.setOpenExternalLinks(True)
doc_label.setWordWrap(True)
layout.addWidget(doc_label)

buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)

@classmethod
def getItem(
cls,
parent: QWidget | None,
title: str,
label: str,
items: list[str],
current: int = 0,
editable: bool = True,
flags: Qt.WindowType = Qt.WindowType.Widget,
inputMethodHints: Qt.InputMethodHint = Qt.InputMethodHint.ImhNone,
doc_text: str = "",
) -> tuple[str, bool]:
"""Mirror QInputDialog.getItem with extra ``doc_text`` rich-text content."""
dialog = cls(
parent,
title,
label,
items,
current,
editable,
flags,
inputMethodHints,
doc_text,
)
if dialog.exec() != QDialog.Accepted:
return "", False
return dialog.item_combo.currentText(), True


class OpenGlobDialog(QDialog):
"""Dialog for selecting a base directory and glob expression."""

def __init__(self, parent: QWidget | None, initial_directory: str) -> None:
super().__init__(parent)
self.setWindowTitle("Open Glob")

layout = QVBoxLayout(self)

directory_label = QLabel("Base folder:")
layout.addWidget(directory_label)

directory_row = QHBoxLayout()
self.directory_edit = QLineEdit(initial_directory)
browse_button = QPushButton("Browse...")
browse_button.clicked.connect(self._choose_directory)
directory_row.addWidget(self.directory_edit, 1)
directory_row.addWidget(browse_button)
layout.addLayout(directory_row)

pattern_label = QLabel("Glob pattern:")
layout.addWidget(pattern_label)

self.pattern_edit = QLineEdit("*.nc")
self.pattern_edit.setPlaceholderText("Examples: *.nc, run*/atm_*.nc, **/*.nc")
layout.addWidget(self.pattern_edit)

hint = QLabel("Use shell-style wildcards. Recursive matching is supported with **.")
hint.setWordWrap(True)
layout.addWidget(hint)

buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)

def _choose_directory(self) -> None:
"""Prompt for a base directory used to resolve glob patterns."""
start_dir = self.directory_edit.text().strip() or str(Path.home())
selected = QFileDialog.getExistingDirectory(self, "Select Base Folder", start_dir)
if selected:
self.directory_edit.setText(selected)

@classmethod
def get_glob_expression(
cls,
parent: QWidget | None,
initial_directory: str,
) -> tuple[str, bool]:
"""Return a '<base>/<pattern>' expression and acceptance state."""
dialog = cls(parent, initial_directory)
if dialog.exec() != QDialog.Accepted:
return "", False

base_dir = dialog.directory_edit.text().strip()
pattern = dialog.pattern_edit.text().strip()
if not base_dir or not pattern:
return "", False

expression = str((Path(base_dir).expanduser() / pattern))
return expression, True
15 changes: 11 additions & 4 deletions xconv2/ui/field_metadata_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ def set_field_list_hint(self, text: str) -> None:
hint_item.setFlags(Qt.NoItemFlags)
self.host.field_list_widget.addItem(hint_item)

def set_selection_info_text(self, text: str) -> None:
"""Update selection detail text in the right-hand info panel."""
self.host.current_selection_info_text = text
info_widget = getattr(self.host, "plot_info_output", None)
if info_widget is not None:
info_widget.setPlainText(text)

def show_selection_properties(self) -> None:
"""Show properties for the currently selected field."""
selected_item = self.host.field_list_widget.currentItem()
Expand Down Expand Up @@ -251,8 +258,8 @@ def populate_field_list(self, fields: Sequence[object]) -> None:
item.setData(Qt.UserRole + 1, properties)
self.host.field_list_widget.addItem(item)

self.set_field_list_visible_rows(5)
self.host.selection_output.setPlainText(
self.set_field_list_visible_rows(self.host._field_list_rows())
self.set_selection_info_text(
f"Loaded {self.host.field_list_widget.count()} fields.\n"
"Click an entry to show field details."
)
Expand All @@ -264,7 +271,7 @@ def on_field_clicked(self, item: QListWidgetItem) -> None:
detail = item.data(Qt.UserRole)
if detail:
detail = "\n".join(detail.splitlines()[2:])
self.host.selection_output.setPlainText(detail)
self.set_selection_info_text(detail)
else:
self.host.selection_output.setPlainText("No additional detail available.")
self.set_selection_info_text("No additional detail available.")
logger.info("Field selected: %s", selected_field)
8 changes: 8 additions & 0 deletions xconv2/ui/menu_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ def setup_menu_bar(self) -> None:
open_zarr_action.triggered.connect(self.host._choose_folder)
file_menu.addAction(open_zarr_action)

open_glob_action = QAction("Open Glob...", self.host)
open_glob_action.triggered.connect(self.host._choose_glob)
file_menu.addAction(open_glob_action)

open_uris_action = QAction("Open URIs...", self.host)
open_uris_action.triggered.connect(self.host._choose_uris)
file_menu.addAction(open_uris_action)

self.host.recent_menu = file_menu.addMenu("Recent")
self.refresh_recent_menu()

Expand Down
46 changes: 42 additions & 4 deletions xconv2/ui/plot_view_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
QFileDialog,
QHBoxLayout,
QLabel,
QPlainTextEdit,
QPushButton,
QSizePolicy,
QStackedLayout,
Expand Down Expand Up @@ -99,6 +100,20 @@ def create_plot_area(self) -> QWidget:
layout = QVBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)

self.host.plot_info_output = QPlainTextEdit()
self.host.plot_info_output.setReadOnly(True)
self.host.plot_info_output.setPlaceholderText("Click a field to see details...")
self.host.plot_info_output.setLineWrapMode(QPlainTextEdit.NoWrap)
self.host.plot_info_output.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.host.plot_info_output.setPlainText(
getattr(self.host, "current_selection_info_text", "No selection info available.")
)

line_height = self.host.plot_info_output.fontMetrics().lineSpacing()
frame_width = self.host.plot_info_output.frameWidth() * 2
margin = 10
self.host.plot_info_output.setFixedHeight((line_height * 6) + frame_width + margin)

self.host.plot_frame = QLabel("Waiting for data...")
self.host.plot_frame.setAlignment(Qt.AlignCenter)
self.host.plot_frame.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
Expand Down Expand Up @@ -162,6 +177,7 @@ def create_plot_area(self) -> QWidget:
summary_row.addWidget(self.host.save_code_button)
summary_row.addWidget(self.host.save_plot_button)

layout.addWidget(self.host.plot_info_output)
layout.addWidget(plot_stack_container, 1)
layout.addLayout(summary_row)
return container
Expand Down Expand Up @@ -334,18 +350,40 @@ def on_save_plot_button_clicked(self) -> None:
if not getattr(self.host, "save_plot_button", None) or not self.host.save_plot_button.isEnabled():
return

default_path = self.host._default_save_path("last_save_plot_dir", "cfview_plot.png")
file_path, _ = QFileDialog.getSaveFileName(
format_filters = {
"png": "PNG files (*.png)",
"svg": "SVG files (*.svg)",
"pdf": "PDF files (*.pdf)",
}
default_format = self.host._default_plot_output_format()
default_filename = self.host._default_plot_filename()
default_path = self.host._default_save_path(
"last_save_plot_dir",
f"{default_filename}.{default_format}",
)

ordered_formats = [default_format] + [fmt for fmt in ("png", "svg", "pdf") if fmt != default_format]
selected_filter = format_filters[default_format]
filters = ";;".join(format_filters[fmt] for fmt in ordered_formats)

file_path, selected_filter = QFileDialog.getSaveFileName(
self.host,
"Save Plot",
default_path,
"PNG files (*.png);;PDF files (*.pdf);;PostScript files (*.ps);;All files (*)",
filters,
selected_filter,
)
if not file_path:
return

selected_ext = default_format
for ext, filt in format_filters.items():
if selected_filter == filt:
selected_ext = ext
break

if not Path(file_path).suffix:
file_path += ".png"
file_path += f".{selected_ext}"

self.host._remember_last_save_dir("last_save_plot_dir", file_path)
self.host._request_plot_save(file_path)
16 changes: 14 additions & 2 deletions xconv2/ui/selection_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@
from typing import TYPE_CHECKING

from PySide6.QtCore import Qt
from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QInputDialog, QLabel, QVBoxLayout, QWidget
from PySide6.QtWidgets import (
QCheckBox,
QHBoxLayout,
QLabel,
QVBoxLayout,
QWidget
)
from superqt import QRangeSlider

from xconv2.cf_templates import collapse_methods
from cftime import num2date
from .dialogs import InputDialogCustom

if TYPE_CHECKING:
from xconv2.core_window import CFVCore
Expand Down Expand Up @@ -133,6 +140,7 @@ def build_dynamic_sliders(self, metadata: dict[str, object]) -> None:

self.update_range_labels(name)

self.host._set_slider_scroll_visible_rows(len(self.host.controls))
self.refresh_plot_summary()
logger.info("Built %d dynamic sliders", len(self.host.controls))

Expand Down Expand Up @@ -172,13 +180,17 @@ def on_collapse_toggled(self, name: str, checked: bool) -> None:
if current_method in collapse_methods
else 0
)
method, ok = QInputDialog.getItem(
method, ok = InputDialogCustom.getItem(
self.host,
"Collapse Method",
f"Select collapse method for {name}:",
collapse_methods,
current_index,
False,
doc_text=(
'Documentation for collapse methods can be found '
'<a href="https://ncas-cms.github.io/cf-python/analysis.html#collapse-methods">online</a>.'
),
)
if ok and method:
self.host.selected_collapse_methods[name] = method
Expand Down
Loading
Loading