Skip to content

Commit fdafd31

Browse files
authored
Recents menu (#39)
Moves load config to its own top-level button, and add a dropdown to it that keeps recent items. Recent items are saved to a machine-wide config (via QSettings) and can be pinned and assigned hotkeys for loading. Adds unit tests for saving and loading recents. Other changes - Add model validation when loading yamls - Cleaning up of tests
1 parent ba14007 commit fdafd31

File tree

6 files changed

+221
-23
lines changed

6 files changed

+221
-23
lines changed

.github/workflows/python-app.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ jobs:
2525
python-version: "3.10"
2626
- name: Install dependencies
2727
run: |
28-
sudo apt-get install -y libgl1 libegl1 libxkbcommon-x11-0 libdbus-1-3
28+
sudo apt-get update
29+
sudo apt-get install --no-install-recommends -y libgl1 libegl1 libxkbcommon-x11-0 libdbus-1-3
2930
pip install -e .[dev]
3031
pip freeze # dump debug info on packages
3132
- name: Check Black style

pyqtgraph_scope_plots/csv/csv_plots.py

Lines changed: 115 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import pyqtgraph as pg
2525
import yaml
2626
from PySide6 import QtWidgets
27-
from PySide6.QtCore import QKeyCombination, QTimer
27+
from PySide6.QtCore import QKeyCombination, QTimer, QSettings
2828
from PySide6.QtGui import QAction, Qt
2929
from PySide6.QtWidgets import (
3030
QWidget,
@@ -36,7 +36,7 @@
3636
QToolButton,
3737
QMessageBox,
3838
)
39-
from pydantic import BaseModel
39+
from pydantic import BaseModel, ValidationError
4040

4141
from ..legend_plot_widget import LegendPlotWidget
4242
from ..xy_plot_legends import XyTableLegends
@@ -177,18 +177,30 @@ def set_thickness(self, thickness: float) -> None:
177177
xy_plot.set_thickness(thickness)
178178

179179

180+
class CsvLoaderRecents(BaseModel):
181+
hotkeys: Dict[int, str] = {} # hotkey number -> file
182+
recents: List[str] = [] # most recent first
183+
184+
180185
class CsvLoaderPlotsTableWidget(AnimationPlotsTableWidget, PlotsTableWidget, HasSaveLoadDataConfig):
181186
"""Example app-level widget that loads CSV files into the plotter"""
182187

183188
_MODEL_BASES = [CsvLoaderStateModel]
184189

185190
WATCH_INTERVAL_MS = 333 # polls the filesystem metadata for changes this frequently
191+
_RECENTS_MAX = 9 # hotkeys + recents is pruned to this count
192+
_RECENTS_CONFIG_KEY = "recents" # for QSettings
186193

187194
_PLOT_TYPE = FullPlots
188195
_TABLE_TYPE = FullSignalsTable
189196

197+
@classmethod
198+
def _config(cls) -> QSettings: # for unit testability
199+
return QSettings("scope-plots", "csv")
200+
190201
def __init__(self, x_axis: Optional[Callable[[], pg.AxisItem]] = None) -> None:
191202
self._x_axis = x_axis
203+
self._loaded_config_abspath = "" # of last loaded config file, even if it has changed
192204

193205
super().__init__()
194206

@@ -267,6 +279,29 @@ def _make_controls(self) -> QWidget:
267279
button_load.setArrowType(Qt.ArrowType.DownArrow)
268280
button_load.setMenu(menu_load)
269281

282+
button_load_config = QToolButton()
283+
button_load_config.setText("Load Config")
284+
button_load_config.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
285+
button_load_config.setSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Fixed)
286+
button_load_config.clicked.connect(self._on_load_config)
287+
288+
self._menu_config = QMenu(self)
289+
button_load_config.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
290+
button_load_config.setArrowType(Qt.ArrowType.DownArrow)
291+
button_load_config.setMenu(self._menu_config)
292+
self._menu_config.aboutToShow.connect(self._populate_config_menu)
293+
294+
self._load_hotkey_actions = []
295+
for i in range(10):
296+
load_hotkey_action = QAction(f"", self)
297+
load_hotkey_action.setShortcut(
298+
QKeyCombination(Qt.KeyboardModifier.ControlModifier, Qt.Key(Qt.Key.Key_0 + i))
299+
)
300+
load_hotkey_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
301+
load_hotkey_action.triggered.connect(partial(self._load_hotkey_slot, i))
302+
self.addAction(load_hotkey_action)
303+
self._load_hotkey_actions.append(load_hotkey_action)
304+
270305
button_refresh = QToolButton()
271306
button_refresh.setText("Refresh CSV")
272307
button_refresh.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
@@ -308,21 +343,81 @@ def _make_controls(self) -> QWidget:
308343
button_menu.addAction(animation_action)
309344
button_visuals.setMenu(button_menu)
310345

311-
save_config_action = QAction("Save Config", button_menu)
312-
save_config_action.triggered.connect(self._on_save_config)
313-
button_menu.addAction(save_config_action)
314-
load_config_action = QAction("Load Config", button_menu)
315-
load_config_action.triggered.connect(self._on_load_config)
316-
button_menu.addAction(load_config_action)
317-
318346
layout = QVBoxLayout()
319347
layout.addWidget(button_load)
348+
layout.addWidget(button_load_config)
320349
layout.addWidget(button_refresh)
321350
layout.addWidget(button_visuals)
322351
widget = QWidget()
323352
widget.setLayout(layout)
324353
return widget
325354

355+
def _get_recents(self) -> CsvLoaderRecents:
356+
recents_val = cast(str, self._config().value(self._RECENTS_CONFIG_KEY, ""))
357+
try:
358+
return CsvLoaderRecents.model_validate(CsvLoaderRecents(**yaml.load(recents_val, Loader=TupleSafeLoader)))
359+
except (yaml.YAMLError, TypeError, ValidationError):
360+
return CsvLoaderRecents()
361+
362+
def _populate_config_menu(self) -> None:
363+
self._menu_config.clear()
364+
save_config_action = QAction("Save Config", self._menu_config)
365+
save_config_action.triggered.connect(self._on_save_config)
366+
self._menu_config.addAction(save_config_action)
367+
368+
self._menu_config.addSeparator()
369+
recents = self._get_recents()
370+
for hotkey, recent in sorted(recents.hotkeys.items(), key=lambda x: x[0]):
371+
load_hotkey_action = self._load_hotkey_actions[hotkey] # crash on invalid index
372+
load_hotkey_action.setText(f"{os.path.split(recent)[1]}")
373+
self._menu_config.addAction(load_hotkey_action)
374+
375+
for recent in recents.recents:
376+
load_action = QAction(f"{os.path.split(recent)[1]}", self._menu_config)
377+
load_action.triggered.connect(partial(self.load_config_file, recent))
378+
self._menu_config.addAction(load_action)
379+
380+
self._menu_config.addSeparator()
381+
set_hotkey_action = QAction("Set Hotkey", self._menu_config)
382+
set_hotkey_action.triggered.connect(self._on_set_hotkey)
383+
if self._loaded_config_abspath:
384+
set_hotkey_action.setText(f"Set Hotkey for {os.path.split(self._loaded_config_abspath)[1]}")
385+
else:
386+
set_hotkey_action.setDisabled(True)
387+
self._menu_config.addAction(set_hotkey_action)
388+
389+
def _on_set_hotkey(self) -> None:
390+
assert self._loaded_config_abspath # shouldn't be triggerable unless something loaded
391+
recents = self._get_recents()
392+
393+
hotkey, ok = QInputDialog.getInt(self, "Set Hotkey Slot", "", value=0, minValue=0, maxValue=9)
394+
if not ok:
395+
return
396+
397+
if self._loaded_config_abspath in recents.recents:
398+
recents.recents.remove(self._loaded_config_abspath)
399+
recents.hotkeys[hotkey] = self._loaded_config_abspath
400+
self._config().setValue(self._RECENTS_CONFIG_KEY, yaml.dump(recents.model_dump(), sort_keys=False))
401+
402+
def _load_hotkey_slot(self, slot: int) -> None:
403+
recents = self._get_recents()
404+
target = recents.hotkeys.get(slot, None)
405+
if target is not None:
406+
self.load_config_file(target)
407+
408+
def _append_recent(self) -> None:
409+
recents = self._get_recents()
410+
if self._loaded_config_abspath in recents.hotkeys.values():
411+
return # don't overwrite hotkeys
412+
if self._loaded_config_abspath in recents.recents:
413+
recents.recents.remove(self._loaded_config_abspath)
414+
recents.recents.insert(0, self._loaded_config_abspath)
415+
excess_recents = len(recents.recents) + len(recents.hotkeys) - self._RECENTS_MAX
416+
if excess_recents > 0:
417+
recents.recents = recents.recents[:-excess_recents]
418+
419+
self._config().setValue(self._RECENTS_CONFIG_KEY, yaml.dump(recents.model_dump(), sort_keys=False))
420+
326421
def _on_load_csv(self) -> None:
327422
csv_filenames, _ = QFileDialog.getOpenFileNames(None, "Select CSV Files", filter="CSV files (*.csv)")
328423
if not csv_filenames: # nothing selected, user canceled
@@ -476,21 +571,24 @@ def _do_save_config(self, filename: str) -> CsvLoaderStateModel:
476571
else: # save as abspath, would need .. access to get CSVs
477572
model.csv_files = [os.path.abspath(csv_filename) for csv_filename in self._csv_data_items.keys()]
478573

574+
self._loaded_config_abspath = os.path.abspath(filename)
575+
self._append_recent()
576+
479577
return model
480578

481579
def _on_load_config(self) -> None:
482580
filename, _ = QFileDialog.getOpenFileName(None, "Load config", filter="YAML files (*.yml)")
483581
if not filename: # nothing selected, user canceled
484582
return
583+
self.load_config_file(filename)
584+
585+
def load_config_file(self, filename: str) -> None:
586+
skeleton_model_type = self._create_skeleton_model_type()
485587
with open(filename, "r") as f:
486-
model = self._parse_config(f)
588+
model = skeleton_model_type.model_validate(skeleton_model_type(**yaml.load(f, Loader=TupleSafeLoader)))
589+
assert isinstance(model, CsvLoaderStateModel)
487590
self._do_load_config(filename, model)
488591

489-
def _parse_config(self, f: Union[TextIO, str]) -> CsvLoaderStateModel:
490-
loaded = self._create_skeleton_model_type()(**yaml.load(f, Loader=TupleSafeLoader))
491-
assert isinstance(loaded, CsvLoaderStateModel)
492-
return loaded
493-
494592
def _do_load_config(self, filename: str, model: CsvLoaderStateModel) -> None:
495593
if model.csv_files is not None:
496594
missing_csv_files = []
@@ -521,3 +619,5 @@ def _do_load_config(self, filename: str, model: CsvLoaderStateModel) -> None:
521619
data_items = [(name, int_color(i), data_type) for i, (name, data_type) in enumerate(self._data_items.items())]
522620
self._set_data_items(data_items)
523621
self._set_data(data) # bulk update everything for performance
622+
self._loaded_config_abspath = os.path.abspath(filename)
623+
self._append_recent()

tests/test_base_plot.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from pyqtgraph_scope_plots.multi_plot_widget import MultiPlotStateModel, PlotWidgetModel
2424
from pyqtgraph_scope_plots import MultiPlotWidget, PlotsTableWidget
2525
from .common_testdata import DATA_ITEMS, DATA
26-
from .test_util import assert_cast
26+
from .util import assert_cast
2727

2828

2929
@pytest.fixture()

tests/test_csv_viewer.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
from unittest import mock
1818

1919
import pytest
20+
from PySide6.QtCore import Qt
21+
from PySide6.QtWidgets import QInputDialog
2022
from pytestqt.qtbot import QtBot
2123

22-
from pyqtgraph_scope_plots.csv.csv_plots import CsvLoaderPlotsTableWidget
24+
from pyqtgraph_scope_plots.csv.csv_plots import CsvLoaderPlotsTableWidget, CsvLoaderRecents
25+
from tests.util import MockQSettings, menu_action_by_name
2326

2427

2528
@pytest.fixture()
@@ -75,6 +78,7 @@ def test_watch_stability(qtbot: QtBot, plot: CsvLoaderPlotsTableWidget) -> None:
7578
qtbot.waitUntil(lambda: mock_load_csv.called) # check the load happens
7679

7780

81+
@mock.patch.object(CsvLoaderPlotsTableWidget, "_config", lambda *args: MockQSettings())
7882
def test_save_model_csvs(qtbot: QtBot, plot: CsvLoaderPlotsTableWidget) -> None:
7983
# test empty save
8084
model = plot._do_save_config(os.path.join(os.path.dirname(__file__), "config.yml"))
@@ -93,6 +97,7 @@ def test_save_model_csvs(qtbot: QtBot, plot: CsvLoaderPlotsTableWidget) -> None:
9397
]
9498

9599

100+
@mock.patch.object(CsvLoaderPlotsTableWidget, "_config", lambda *args: MockQSettings())
96101
def test_load_model_csvs_relpath(qtbot: QtBot, plot: CsvLoaderPlotsTableWidget) -> None:
97102
model = plot._do_save_config("/config.yml")
98103

@@ -114,3 +119,80 @@ def test_load_model_csvs_relpath(qtbot: QtBot, plot: CsvLoaderPlotsTableWidget)
114119
mock_load_csv.assert_called_with(
115120
[os.path.join(os.path.dirname(__file__), "data", "test_csv_viewer_data.csv")], update=False
116121
)
122+
123+
124+
def test_recents_save(qtbot: QtBot, plot: CsvLoaderPlotsTableWidget) -> None:
125+
settings = MockQSettings()
126+
with mock.patch.object(CsvLoaderPlotsTableWidget, "_config", lambda *args: settings):
127+
assert plot._get_recents() == CsvLoaderRecents()
128+
model = plot._do_save_config("/config.yml") # stores to recents
129+
assert plot._get_recents().recents == [os.path.abspath("/config.yml")]
130+
131+
plot._do_load_config(os.path.join(os.path.dirname(__file__), "test.yml"), model)
132+
assert plot._get_recents().recents == [
133+
os.path.abspath(os.path.join(os.path.dirname(__file__), "test.yml")),
134+
os.path.abspath("/config.yml"),
135+
]
136+
137+
# check reordering latest-first + dedup
138+
plot._do_load_config("/config.yml", model)
139+
assert plot._get_recents().recents == [
140+
os.path.abspath("/config.yml"),
141+
os.path.abspath(os.path.join(os.path.dirname(__file__), "test.yml")),
142+
]
143+
144+
# check pruning
145+
for i in range(10):
146+
plot._do_load_config(f"/extra{i}.yml", model)
147+
assert len(plot._get_recents().recents) == 9
148+
149+
# check hotkeys
150+
with mock.patch.object(QInputDialog, "getInt", lambda *args, **kwargs: (8, True)):
151+
plot._on_set_hotkey()
152+
assert plot._get_recents().hotkeys[8] == os.path.abspath(f"/extra9.yml")
153+
assert len(plot._get_recents().recents) == 8
154+
assert os.path.abspath(f"/extra9.yml") not in plot._get_recents().recents
155+
assert os.path.abspath(f"/extra8.yml") in plot._get_recents().recents
156+
157+
# check most recent pruned
158+
plot._do_load_config(f"/extra11.yml", model)
159+
assert plot._get_recents().hotkeys[8] == os.path.abspath(f"/extra9.yml")
160+
assert len(plot._get_recents().recents) == 8
161+
assert os.path.abspath(f"/extra0.yml") not in plot._get_recents().recents
162+
assert os.path.abspath(f"/extra11.yml") in plot._get_recents().recents
163+
164+
# check that it is possible to max out the hotkey range
165+
for i in range(10):
166+
plot._do_load_config(f"/extra{i}.yml", model)
167+
with mock.patch.object(QInputDialog, "getInt", lambda *args, **kwargs: (i, True)):
168+
plot._on_set_hotkey()
169+
assert len(plot._get_recents().recents) == 0
170+
171+
# recents no longer saves, hotkeys take priority
172+
plot._do_load_config(f"/extra11.yml", model)
173+
assert len(plot._get_recents().recents) == 0
174+
175+
176+
@mock.patch.object(CsvLoaderPlotsTableWidget, "load_config_file")
177+
def test_recents_load(mock_load_config: mock.MagicMock, qtbot: QtBot, plot: CsvLoaderPlotsTableWidget) -> None:
178+
settings = MockQSettings()
179+
with mock.patch.object(CsvLoaderPlotsTableWidget, "_config", lambda *args: settings):
180+
plot._populate_config_menu()
181+
assert len(plot._menu_config.actions()) == 4 # 2 empty items + separators
182+
183+
qtbot.keyClick(plot, Qt.Key.Key_4, modifier=Qt.KeyboardModifier.ControlModifier) # check nothing happens
184+
185+
plot._do_save_config("/config.yml") # stores to recents
186+
plot._populate_config_menu()
187+
assert len(plot._menu_config.actions()) == 5
188+
load_action = menu_action_by_name(plot._menu_config, "config.yml")
189+
assert load_action is not None
190+
load_action.trigger()
191+
mock_load_config.assert_called_once_with(os.path.abspath("/config.yml"))
192+
mock_load_config.reset_mock()
193+
194+
# set and test hotkey
195+
with mock.patch.object(QInputDialog, "getInt", lambda *args, **kwargs: (4, True)):
196+
plot._on_set_hotkey()
197+
qtbot.keyClick(plot, Qt.Key.Key_4, modifier=Qt.KeyboardModifier.ControlModifier)
198+
mock_load_config.assert_called_once_with(os.path.abspath("/config.yml"))

tests/test_transforms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from pyqtgraph_scope_plots.transforms_signal_table import TransformsDataStateModel
2626
from pyqtgraph_scope_plots.util.util import not_none
2727
from .common_testdata import DATA
28-
from .test_util import context_menu, menu_action_by_name
28+
from .util import context_menu, menu_action_by_name
2929

3030

3131
@pytest.fixture()

tests/test_util.py renamed to tests/util.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from typing import TypeVar, Type, Any
15+
from typing import TypeVar, Type, Any, Optional, Dict
16+
from unittest import mock
1617

1718
from PySide6.QtCore import Qt, QPoint
1819
from PySide6.QtGui import QAction
@@ -41,7 +42,7 @@ def context_menu(qtbot: QtBot, container: QWidget, target: QPoint = QPoint(0, 0)
4142
return assert_cast(QMenu, container.findChild(QMenu))
4243

4344

44-
def menu_action_by_name(menu: QMenu, *text: str) -> QAction:
45+
def menu_action_by_name(menu: QMenu, *text: str) -> Optional[QAction]:
4546
"""Given a menu, returns the first action that contains the specified text, case-insensitive"""
4647
for i, current_text in enumerate(text):
4748
item_found = False
@@ -53,5 +54,19 @@ def menu_action_by_name(menu: QMenu, *text: str) -> QAction:
5354
else: # final step, return the action
5455
return action
5556
if not item_found:
56-
raise ValueError(f"No menu item with {current_text} in {[action.text() for action in menu.actions()]}")
57-
raise ValueError() # shouldn't happen, here to satisfy the type checker
57+
return None
58+
return None # shouldn't happen, here to satisfy the type checker
59+
60+
61+
class MockQSettings(mock.MagicMock):
62+
def __init__(self, *args: Any, settings_values: Optional[Dict[str, Any]] = None, **kwargs: Any) -> None:
63+
super().__init__(*args, **kwargs)
64+
self._values: Dict[str, Any] = {}
65+
if settings_values:
66+
self._values.update(settings_values)
67+
68+
def value(self, key: str, default: Any = None) -> Optional[Any]:
69+
return self._values.get(key, default)
70+
71+
def setValue(self, key: str, value: Any) -> None:
72+
self._values[key] = value

0 commit comments

Comments
 (0)