Skip to content

Commit dfffd00

Browse files
author
Patrick Smith
committed
Add "Replace" action to File menu for swapping MDF files with preserved display config
When working with multiple MDF files that share the same signal layout, switching files while keeping the current display configuration (plot windows, selected channels, etc.) previously required multiple manual steps. The new File > Replace action (Ctrl+Shift+O) captures the current display config via to_config(), closes the old tab, and opens the selected file with display_file=config to restore the layout automatically.
1 parent 95b6330 commit dfffd00

File tree

2 files changed

+258
-0
lines changed

2 files changed

+258
-0
lines changed

src/asammdf/gui/widgets/main.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ def __init__(self, files=None, *args, **kwargs):
114114
action.triggered.connect(self.open_folder)
115115
open_group.addAction(action)
116116

117+
action = QtGui.QAction(icon, "Replace", menu)
118+
action.triggered.connect(self.replace_file)
119+
action.setShortcut(QtGui.QKeySequence("Ctrl+Shift+O"))
120+
open_group.addAction(action)
121+
117122
menu.addActions(open_group.actions())
118123

119124
menu.addSeparator()
@@ -1481,6 +1486,94 @@ def open_folder(self, event):
14811486
self.batch._ignore = False
14821487
self.batch.update_channel_tree()
14831488

1489+
def replace_file(self, event=None):
1490+
# Only works in single-file mode with at least one tab open
1491+
if self.stackedWidget.currentIndex() != 0:
1492+
return
1493+
1494+
current_index = self.files.currentIndex()
1495+
if current_index < 0:
1496+
return
1497+
1498+
current_widget = self.files.widget(current_index)
1499+
if current_widget is None:
1500+
return
1501+
1502+
# Capture current display config before closing
1503+
config = current_widget.to_config()
1504+
1505+
# Show single-file open dialog
1506+
system = platform.system().lower()
1507+
if system == "linux":
1508+
file_name, _ = QtWidgets.QFileDialog.getOpenFileName(
1509+
self,
1510+
"Select replacement measurement file",
1511+
self._settings.value("last_opened_path", "", str),
1512+
"CSV (*.csv);;MDF v3 (*.dat *.mdf);;MDF v4(*.mf4 *.mf4z);;DL3/ERG files (*.dl3 *.erg);;All files (*.csv *.dat *.mdf *.mf4 *.mf4z *.dl3 *.erg)",
1513+
"All files (*.csv *.dat *.mdf *.mf4 *.mf4z *.dl3 *.erg)",
1514+
options=QtWidgets.QFileDialog.Option.DontUseNativeDialog,
1515+
)
1516+
else:
1517+
file_name, _ = QtWidgets.QFileDialog.getOpenFileName(
1518+
self,
1519+
"Select replacement measurement file",
1520+
self._settings.value("last_opened_path", "", str),
1521+
"CSV (*.csv);;MDF v3 (*.dat *.mdf);;MDF v4(*.mf4 *.mf4z);;DL3/ERG files (*.dl3 *.erg);;All files (*.csv *.dat *.mdf *.mf4 *.mf4z *.dl3 *.erg)",
1522+
"All files (*.csv *.dat *.mdf *.mf4 *.mf4z *.dl3 *.erg)",
1523+
)
1524+
1525+
if not file_name:
1526+
return
1527+
1528+
self._settings.setValue("last_opened_path", file_name)
1529+
file_name = Path(file_name)
1530+
1531+
# Close old tab, bypassing the unsaved-display-file dialog
1532+
# since we already captured the config above
1533+
current_widget.clear_windows(is_closing=True)
1534+
if current_widget.mdf is not None:
1535+
mdf_name = current_widget.mdf.name
1536+
current_widget.mdf.close()
1537+
if mdf_name != current_widget.mdf.original_name and mdf_name.is_file():
1538+
mdf_name.unlink()
1539+
current_widget.channels_tree.clear()
1540+
current_widget.filter_tree.clear()
1541+
current_widget.mdf = None
1542+
current_widget.setParent(None)
1543+
current_widget.deleteLater()
1544+
1545+
gc.collect()
1546+
1547+
# Open new file with the captured display config
1548+
try:
1549+
widget = FileWidget(
1550+
file_name,
1551+
self.with_dots,
1552+
self.subplots,
1553+
self.subplots_link,
1554+
self.ignore_value2text_conversions,
1555+
self.display_cg_name,
1556+
self.line_interconnect,
1557+
None,
1558+
False,
1559+
False,
1560+
self,
1561+
ignore_invalidation_bits=self.ignore_invalidation_bits,
1562+
display_file=config,
1563+
)
1564+
except:
1565+
raise
1566+
else:
1567+
widget.mdf.configure(integer_interpolation=self.integer_interpolation)
1568+
self.files.insertTab(current_index, widget, file_name.name)
1569+
self.files.setTabToolTip(current_index, str(file_name))
1570+
self.files.setCurrentIndex(current_index)
1571+
widget.open_new_files.connect(self._open_file)
1572+
widget.full_screen_toggled.connect(self.toggle_fullscreen)
1573+
self.edit_cursor_options()
1574+
1575+
widget.finalize_init()
1576+
14841577
def close_file(self, index):
14851578
widget = self.files.widget(index)
14861579
if widget:

test/asammdf/gui/widgets/Shortcuts/test_MainWindow_Shortcuts.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
#!/usr/bin/env python
22
from pathlib import Path
3+
import shutil
34
from unittest import mock
45

6+
from PySide6 import QtCore
57
from PySide6.QtGui import QKeySequence
68
from PySide6.QtTest import QTest
79
from PySide6.QtWidgets import QApplication, QTreeWidgetItemIterator
@@ -35,6 +37,10 @@ def setUp(self):
3537

3638
def destroyMW(self):
3739
if self.mw:
40+
for i in range(self.mw.files.count()):
41+
widget = self.mw.files.widget(i)
42+
if widget is not None:
43+
widget.clear_windows(is_closing=True)
3844
self.mw.close()
3945
self.mw.deleteLater()
4046

@@ -216,3 +222,162 @@ def test_create_plot__numeric__tabular_sub_window_shortcut(self):
216222
self.assertEqual(len(matrix_items), signals.columnHeader.model().columnCount() - 1)
217223
for key in matrix_items.values():
218224
self.assertIn(key, signals.pgdf.df.keys())
225+
226+
227+
class TestReplaceFile(TestBase):
228+
@safe_setup
229+
def setUp(self):
230+
super().setUp()
231+
# Create two copies of the test file so we have distinct paths
232+
self.measurement_file = str(
233+
shutil.copy(Path(TestBase.resource, "ASAP2_Demo_V171.mf4"), Path(self.test_workspace, "original.mf4"))
234+
)
235+
self.replacement_file = str(
236+
shutil.copy(Path(TestBase.resource, "ASAP2_Demo_V171.mf4"), Path(self.test_workspace, "replacement.mf4"))
237+
)
238+
self.mw = None
239+
240+
def tearDown(self):
241+
if self.mw:
242+
for i in range(self.mw.files.count()):
243+
widget = self.mw.files.widget(i)
244+
if widget is not None:
245+
widget.clear_windows(is_closing=True)
246+
self.mw.close()
247+
self.mw.deleteLater()
248+
super().tearDown()
249+
250+
def test_replace_file_preserves_tab_position(self):
251+
"""
252+
Test scope:
253+
Ensure that File > Replace replaces the current file tab in place.
254+
Events:
255+
- Open MainWindow with a measurement file
256+
- Trigger Replace via Ctrl+Shift+O with mocked file dialog
257+
Evaluate:
258+
- Tab count remains 1
259+
- Tab label matches replacement file name
260+
- Tab tooltip matches replacement file path
261+
- FileWidget is valid
262+
"""
263+
self.mw = MainWindow(files=(self.measurement_file,))
264+
self.mw.showNormal()
265+
self.processEvents(1)
266+
267+
self.assertEqual(self.mw.files.count(), 1)
268+
self.assertEqual(self.mw.files.tabText(0), "original.mf4")
269+
270+
with mock.patch("asammdf.gui.widgets.main.QtWidgets.QFileDialog.getOpenFileName") as mo_dialog:
271+
mo_dialog.return_value = self.replacement_file, None
272+
QTest.keySequence(self.mw, QKeySequence("Ctrl+Shift+O"))
273+
self.processEvents(1)
274+
275+
# Tab count should still be 1
276+
self.assertEqual(self.mw.files.count(), 1)
277+
# Tab label and tooltip should reflect the new file
278+
self.assertEqual(self.mw.files.tabText(0), "replacement.mf4")
279+
self.assertIn("replacement.mf4", self.mw.files.tabToolTip(0))
280+
# Widget should be a valid FileWidget
281+
self.assertIsInstance(self.mw.files.widget(0), FileWidget)
282+
283+
def test_replace_file_preserves_display_config(self):
284+
"""
285+
Test scope:
286+
Ensure that File > Replace preserves the display configuration (sub-windows)
287+
from the old file and applies it to the new file.
288+
Events:
289+
- Open MainWindow with a measurement file
290+
- Create a Plot sub-window with selected channels
291+
- Trigger Replace via Ctrl+Shift+O with mocked file dialog
292+
Evaluate:
293+
- New file has the same number of sub-windows
294+
- Sub-window type is Plot
295+
- Plot contains the same channels
296+
"""
297+
self.mw = MainWindow(files=(self.measurement_file,))
298+
self.mw.showNormal()
299+
self.processEvents(1)
300+
301+
file_widget = self.mw.files.widget(0)
302+
303+
# Select some channels and create a Plot window
304+
channel_names = []
305+
iterator = QTreeWidgetItemIterator(file_widget.channels_tree)
306+
count = 0
307+
while item := iterator.value():
308+
if item.parent() is not None and count < 3:
309+
item.setCheckState(0, QtCore.Qt.CheckState.Checked)
310+
channel_names.append(item.text(0))
311+
count += 1
312+
iterator += 1
313+
314+
self.assertGreater(len(channel_names), 0, "No channels found to select")
315+
316+
# Create a Plot window directly (F2 shortcut is tested separately in TestShortcuts)
317+
file_widget._create_window(None, "Plot")
318+
self.processEvents(0.5)
319+
320+
self.assertEqual(len(file_widget.mdi_area.subWindowList()), 1)
321+
322+
with mock.patch("asammdf.gui.widgets.main.QtWidgets.QFileDialog.getOpenFileName") as mo_dialog:
323+
mo_dialog.return_value = self.replacement_file, None
324+
QTest.keySequence(self.mw, QKeySequence("Ctrl+Shift+O"))
325+
self.processEvents(1)
326+
327+
new_widget = self.mw.files.widget(0)
328+
self.assertIsInstance(new_widget, FileWidget)
329+
# Verify sub-windows were recreated
330+
sub_windows = new_widget.mdi_area.subWindowList()
331+
self.assertEqual(len(sub_windows), 1)
332+
self.assertIsInstance(sub_windows[0].widget(), plot.Plot)
333+
334+
def test_replace_file_no_op_when_no_file_open(self):
335+
"""
336+
Test scope:
337+
Ensure that Replace does nothing when no file tab is open.
338+
Events:
339+
- Open MainWindow with no files
340+
- Trigger Replace via Ctrl+Shift+O
341+
Evaluate:
342+
- Tab count remains 0
343+
- No errors raised
344+
"""
345+
self.mw = MainWindow()
346+
self.mw.showNormal()
347+
self.processEvents(0.5)
348+
349+
self.assertEqual(self.mw.files.count(), 0)
350+
351+
with mock.patch("asammdf.gui.widgets.main.QtWidgets.QFileDialog.getOpenFileName") as mo_dialog:
352+
QTest.keySequence(self.mw, QKeySequence("Ctrl+Shift+O"))
353+
self.processEvents(0.5)
354+
# Dialog should never be shown
355+
mo_dialog.assert_not_called()
356+
357+
self.assertEqual(self.mw.files.count(), 0)
358+
359+
def test_replace_file_no_op_when_dialog_cancelled(self):
360+
"""
361+
Test scope:
362+
Ensure that Replace does nothing when the user cancels the file dialog.
363+
Events:
364+
- Open MainWindow with a measurement file
365+
- Trigger Replace but return empty string from dialog (user cancelled)
366+
Evaluate:
367+
- Original file tab is unchanged
368+
"""
369+
self.mw = MainWindow(files=(self.measurement_file,))
370+
self.mw.showNormal()
371+
self.processEvents(1)
372+
373+
self.assertEqual(self.mw.files.count(), 1)
374+
self.assertEqual(self.mw.files.tabText(0), "original.mf4")
375+
376+
with mock.patch("asammdf.gui.widgets.main.QtWidgets.QFileDialog.getOpenFileName") as mo_dialog:
377+
mo_dialog.return_value = "", None
378+
QTest.keySequence(self.mw, QKeySequence("Ctrl+Shift+O"))
379+
self.processEvents(0.5)
380+
381+
# Original tab should be unchanged
382+
self.assertEqual(self.mw.files.count(), 1)
383+
self.assertEqual(self.mw.files.tabText(0), "original.mf4")

0 commit comments

Comments
 (0)