Skip to content

Commit f4f4665

Browse files
authored
Merge pull request #24 from NCAS-CMS/filehandling
Filehandling and sundry issues.
2 parents e598823 + 82063e9 commit f4f4665

12 files changed

+498
-80
lines changed

tests/test_cf_interface.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ def __call__(self) -> str:
2626
class _MockField:
2727
shape = (2, 3)
2828

29+
def __str__(self) -> str:
30+
return "mock-field-summary"
31+
2932
def identity(self) -> str:
3033
return "air_temperature"
3134

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

6164

xconv2/assets/cf-logo-box.png

101 KB
Loading

xconv2/assets/cf-logo-box.svg

Lines changed: 12 additions & 0 deletions
Loading

xconv2/core_window.py

Lines changed: 227 additions & 53 deletions
Large diffs are not rendered by default.

xconv2/ui/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
"""UI support modules for the xconv2 main window."""
2+
3+
from .dialogs import InputDialogCustom, OpenGlobDialog
4+
5+
__all__ = ["InputDialogCustom", "OpenGlobDialog"]

xconv2/ui/dialogs.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
from PySide6.QtCore import Qt
6+
from PySide6.QtWidgets import (
7+
QFileDialog,
8+
QComboBox,
9+
QDialog,
10+
QDialogButtonBox,
11+
QHBoxLayout,
12+
QLabel,
13+
QLineEdit,
14+
QPushButton,
15+
QVBoxLayout,
16+
QWidget,
17+
)
18+
19+
20+
class InputDialogCustom(QDialog):
21+
"""Reusable item chooser with optional rich-text documentation below input."""
22+
23+
def __init__(
24+
self,
25+
parent: QWidget | None,
26+
title: str,
27+
label: str,
28+
items: list[str],
29+
current_index: int,
30+
editable: bool,
31+
flags: Qt.WindowType,
32+
input_method_hints: Qt.InputMethodHint,
33+
doc_text: str,
34+
) -> None:
35+
super().__init__(parent, flags)
36+
self.setWindowTitle(title)
37+
38+
layout = QVBoxLayout(self)
39+
40+
prompt = QLabel(label)
41+
layout.addWidget(prompt)
42+
43+
self.item_combo = QComboBox()
44+
self.item_combo.addItems(items)
45+
self.item_combo.setEditable(editable)
46+
self.item_combo.setInputMethodHints(input_method_hints)
47+
if items:
48+
self.item_combo.setCurrentIndex(max(0, min(current_index, len(items) - 1)))
49+
layout.addWidget(self.item_combo)
50+
51+
if doc_text:
52+
doc_label = QLabel(doc_text)
53+
doc_label.setTextFormat(Qt.RichText)
54+
doc_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
55+
doc_label.setOpenExternalLinks(True)
56+
doc_label.setWordWrap(True)
57+
layout.addWidget(doc_label)
58+
59+
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
60+
buttons.accepted.connect(self.accept)
61+
buttons.rejected.connect(self.reject)
62+
layout.addWidget(buttons)
63+
64+
@classmethod
65+
def getItem(
66+
cls,
67+
parent: QWidget | None,
68+
title: str,
69+
label: str,
70+
items: list[str],
71+
current: int = 0,
72+
editable: bool = True,
73+
flags: Qt.WindowType = Qt.WindowType.Widget,
74+
inputMethodHints: Qt.InputMethodHint = Qt.InputMethodHint.ImhNone,
75+
doc_text: str = "",
76+
) -> tuple[str, bool]:
77+
"""Mirror QInputDialog.getItem with extra ``doc_text`` rich-text content."""
78+
dialog = cls(
79+
parent,
80+
title,
81+
label,
82+
items,
83+
current,
84+
editable,
85+
flags,
86+
inputMethodHints,
87+
doc_text,
88+
)
89+
if dialog.exec() != QDialog.Accepted:
90+
return "", False
91+
return dialog.item_combo.currentText(), True
92+
93+
94+
class OpenGlobDialog(QDialog):
95+
"""Dialog for selecting a base directory and glob expression."""
96+
97+
def __init__(self, parent: QWidget | None, initial_directory: str) -> None:
98+
super().__init__(parent)
99+
self.setWindowTitle("Open Glob")
100+
101+
layout = QVBoxLayout(self)
102+
103+
directory_label = QLabel("Base folder:")
104+
layout.addWidget(directory_label)
105+
106+
directory_row = QHBoxLayout()
107+
self.directory_edit = QLineEdit(initial_directory)
108+
browse_button = QPushButton("Browse...")
109+
browse_button.clicked.connect(self._choose_directory)
110+
directory_row.addWidget(self.directory_edit, 1)
111+
directory_row.addWidget(browse_button)
112+
layout.addLayout(directory_row)
113+
114+
pattern_label = QLabel("Glob pattern:")
115+
layout.addWidget(pattern_label)
116+
117+
self.pattern_edit = QLineEdit("*.nc")
118+
self.pattern_edit.setPlaceholderText("Examples: *.nc, run*/atm_*.nc, **/*.nc")
119+
layout.addWidget(self.pattern_edit)
120+
121+
hint = QLabel("Use shell-style wildcards. Recursive matching is supported with **.")
122+
hint.setWordWrap(True)
123+
layout.addWidget(hint)
124+
125+
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
126+
buttons.accepted.connect(self.accept)
127+
buttons.rejected.connect(self.reject)
128+
layout.addWidget(buttons)
129+
130+
def _choose_directory(self) -> None:
131+
"""Prompt for a base directory used to resolve glob patterns."""
132+
start_dir = self.directory_edit.text().strip() or str(Path.home())
133+
selected = QFileDialog.getExistingDirectory(self, "Select Base Folder", start_dir)
134+
if selected:
135+
self.directory_edit.setText(selected)
136+
137+
@classmethod
138+
def get_glob_expression(
139+
cls,
140+
parent: QWidget | None,
141+
initial_directory: str,
142+
) -> tuple[str, bool]:
143+
"""Return a '<base>/<pattern>' expression and acceptance state."""
144+
dialog = cls(parent, initial_directory)
145+
if dialog.exec() != QDialog.Accepted:
146+
return "", False
147+
148+
base_dir = dialog.directory_edit.text().strip()
149+
pattern = dialog.pattern_edit.text().strip()
150+
if not base_dir or not pattern:
151+
return "", False
152+
153+
expression = str((Path(base_dir).expanduser() / pattern))
154+
return expression, True

xconv2/ui/field_metadata_controller.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ def set_field_list_hint(self, text: str) -> None:
4040
hint_item.setFlags(Qt.NoItemFlags)
4141
self.host.field_list_widget.addItem(hint_item)
4242

43+
def set_selection_info_text(self, text: str) -> None:
44+
"""Update selection detail text in the right-hand info panel."""
45+
self.host.current_selection_info_text = text
46+
info_widget = getattr(self.host, "plot_info_output", None)
47+
if info_widget is not None:
48+
info_widget.setPlainText(text)
49+
4350
def show_selection_properties(self) -> None:
4451
"""Show properties for the currently selected field."""
4552
selected_item = self.host.field_list_widget.currentItem()
@@ -251,8 +258,8 @@ def populate_field_list(self, fields: Sequence[object]) -> None:
251258
item.setData(Qt.UserRole + 1, properties)
252259
self.host.field_list_widget.addItem(item)
253260

254-
self.set_field_list_visible_rows(5)
255-
self.host.selection_output.setPlainText(
261+
self.set_field_list_visible_rows(self.host._field_list_rows())
262+
self.set_selection_info_text(
256263
f"Loaded {self.host.field_list_widget.count()} fields.\n"
257264
"Click an entry to show field details."
258265
)
@@ -264,7 +271,7 @@ def on_field_clicked(self, item: QListWidgetItem) -> None:
264271
detail = item.data(Qt.UserRole)
265272
if detail:
266273
detail = "\n".join(detail.splitlines()[2:])
267-
self.host.selection_output.setPlainText(detail)
274+
self.set_selection_info_text(detail)
268275
else:
269-
self.host.selection_output.setPlainText("No additional detail available.")
276+
self.set_selection_info_text("No additional detail available.")
270277
logger.info("Field selected: %s", selected_field)

xconv2/ui/menu_controller.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ def setup_menu_bar(self) -> None:
5656
open_zarr_action.triggered.connect(self.host._choose_folder)
5757
file_menu.addAction(open_zarr_action)
5858

59+
open_glob_action = QAction("Open Glob...", self.host)
60+
open_glob_action.triggered.connect(self.host._choose_glob)
61+
file_menu.addAction(open_glob_action)
62+
63+
open_uris_action = QAction("Open URIs...", self.host)
64+
open_uris_action.triggered.connect(self.host._choose_uris)
65+
file_menu.addAction(open_uris_action)
66+
5967
self.host.recent_menu = file_menu.addMenu("Recent")
6068
self.refresh_recent_menu()
6169

xconv2/ui/plot_view_controller.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
QFileDialog,
1414
QHBoxLayout,
1515
QLabel,
16+
QPlainTextEdit,
1617
QPushButton,
1718
QSizePolicy,
1819
QStackedLayout,
@@ -99,6 +100,20 @@ def create_plot_area(self) -> QWidget:
99100
layout = QVBoxLayout(container)
100101
layout.setContentsMargins(0, 0, 0, 0)
101102

103+
self.host.plot_info_output = QPlainTextEdit()
104+
self.host.plot_info_output.setReadOnly(True)
105+
self.host.plot_info_output.setPlaceholderText("Click a field to see details...")
106+
self.host.plot_info_output.setLineWrapMode(QPlainTextEdit.NoWrap)
107+
self.host.plot_info_output.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
108+
self.host.plot_info_output.setPlainText(
109+
getattr(self.host, "current_selection_info_text", "No selection info available.")
110+
)
111+
112+
line_height = self.host.plot_info_output.fontMetrics().lineSpacing()
113+
frame_width = self.host.plot_info_output.frameWidth() * 2
114+
margin = 10
115+
self.host.plot_info_output.setFixedHeight((line_height * 6) + frame_width + margin)
116+
102117
self.host.plot_frame = QLabel("Waiting for data...")
103118
self.host.plot_frame.setAlignment(Qt.AlignCenter)
104119
self.host.plot_frame.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
@@ -162,6 +177,7 @@ def create_plot_area(self) -> QWidget:
162177
summary_row.addWidget(self.host.save_code_button)
163178
summary_row.addWidget(self.host.save_plot_button)
164179

180+
layout.addWidget(self.host.plot_info_output)
165181
layout.addWidget(plot_stack_container, 1)
166182
layout.addLayout(summary_row)
167183
return container
@@ -334,18 +350,40 @@ def on_save_plot_button_clicked(self) -> None:
334350
if not getattr(self.host, "save_plot_button", None) or not self.host.save_plot_button.isEnabled():
335351
return
336352

337-
default_path = self.host._default_save_path("last_save_plot_dir", "cfview_plot.png")
338-
file_path, _ = QFileDialog.getSaveFileName(
353+
format_filters = {
354+
"png": "PNG files (*.png)",
355+
"svg": "SVG files (*.svg)",
356+
"pdf": "PDF files (*.pdf)",
357+
}
358+
default_format = self.host._default_plot_output_format()
359+
default_filename = self.host._default_plot_filename()
360+
default_path = self.host._default_save_path(
361+
"last_save_plot_dir",
362+
f"{default_filename}.{default_format}",
363+
)
364+
365+
ordered_formats = [default_format] + [fmt for fmt in ("png", "svg", "pdf") if fmt != default_format]
366+
selected_filter = format_filters[default_format]
367+
filters = ";;".join(format_filters[fmt] for fmt in ordered_formats)
368+
369+
file_path, selected_filter = QFileDialog.getSaveFileName(
339370
self.host,
340371
"Save Plot",
341372
default_path,
342-
"PNG files (*.png);;PDF files (*.pdf);;PostScript files (*.ps);;All files (*)",
373+
filters,
374+
selected_filter,
343375
)
344376
if not file_path:
345377
return
346378

379+
selected_ext = default_format
380+
for ext, filt in format_filters.items():
381+
if selected_filter == filt:
382+
selected_ext = ext
383+
break
384+
347385
if not Path(file_path).suffix:
348-
file_path += ".png"
386+
file_path += f".{selected_ext}"
349387

350388
self.host._remember_last_save_dir("last_save_plot_dir", file_path)
351389
self.host._request_plot_save(file_path)

xconv2/ui/selection_controller.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@
44
from typing import TYPE_CHECKING
55

66
from PySide6.QtCore import Qt
7-
from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QInputDialog, QLabel, QVBoxLayout, QWidget
7+
from PySide6.QtWidgets import (
8+
QCheckBox,
9+
QHBoxLayout,
10+
QLabel,
11+
QVBoxLayout,
12+
QWidget
13+
)
814
from superqt import QRangeSlider
915

1016
from xconv2.cf_templates import collapse_methods
1117
from cftime import num2date
18+
from .dialogs import InputDialogCustom
1219

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

134141
self.update_range_labels(name)
135142

143+
self.host._set_slider_scroll_visible_rows(len(self.host.controls))
136144
self.refresh_plot_summary()
137145
logger.info("Built %d dynamic sliders", len(self.host.controls))
138146

@@ -172,13 +180,17 @@ def on_collapse_toggled(self, name: str, checked: bool) -> None:
172180
if current_method in collapse_methods
173181
else 0
174182
)
175-
method, ok = QInputDialog.getItem(
183+
method, ok = InputDialogCustom.getItem(
176184
self.host,
177185
"Collapse Method",
178186
f"Select collapse method for {name}:",
179187
collapse_methods,
180188
current_index,
181189
False,
190+
doc_text=(
191+
'Documentation for collapse methods can be found '
192+
'<a href="https://ncas-cms.github.io/cf-python/analysis.html#collapse-methods">online</a>.'
193+
),
182194
)
183195
if ok and method:
184196
self.host.selected_collapse_methods[name] = method

0 commit comments

Comments
 (0)