|
24 | 24 | import pyqtgraph as pg
|
25 | 25 | import yaml
|
26 | 26 | from PySide6 import QtWidgets
|
27 |
| -from PySide6.QtCore import QKeyCombination, QTimer |
| 27 | +from PySide6.QtCore import QKeyCombination, QTimer, QSettings |
28 | 28 | from PySide6.QtGui import QAction, Qt
|
29 | 29 | from PySide6.QtWidgets import (
|
30 | 30 | QWidget,
|
|
36 | 36 | QToolButton,
|
37 | 37 | QMessageBox,
|
38 | 38 | )
|
39 |
| -from pydantic import BaseModel |
| 39 | +from pydantic import BaseModel, ValidationError |
40 | 40 |
|
41 | 41 | from ..legend_plot_widget import LegendPlotWidget
|
42 | 42 | from ..xy_plot_legends import XyTableLegends
|
@@ -177,18 +177,30 @@ def set_thickness(self, thickness: float) -> None:
|
177 | 177 | xy_plot.set_thickness(thickness)
|
178 | 178 |
|
179 | 179 |
|
| 180 | +class CsvLoaderRecents(BaseModel): |
| 181 | + hotkeys: Dict[int, str] = {} # hotkey number -> file |
| 182 | + recents: List[str] = [] # most recent first |
| 183 | + |
| 184 | + |
180 | 185 | class CsvLoaderPlotsTableWidget(AnimationPlotsTableWidget, PlotsTableWidget, HasSaveLoadDataConfig):
|
181 | 186 | """Example app-level widget that loads CSV files into the plotter"""
|
182 | 187 |
|
183 | 188 | _MODEL_BASES = [CsvLoaderStateModel]
|
184 | 189 |
|
185 | 190 | 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 |
186 | 193 |
|
187 | 194 | _PLOT_TYPE = FullPlots
|
188 | 195 | _TABLE_TYPE = FullSignalsTable
|
189 | 196 |
|
| 197 | + @classmethod |
| 198 | + def _config(cls) -> QSettings: # for unit testability |
| 199 | + return QSettings("scope-plots", "csv") |
| 200 | + |
190 | 201 | def __init__(self, x_axis: Optional[Callable[[], pg.AxisItem]] = None) -> None:
|
191 | 202 | self._x_axis = x_axis
|
| 203 | + self._loaded_config_abspath = "" # of last loaded config file, even if it has changed |
192 | 204 |
|
193 | 205 | super().__init__()
|
194 | 206 |
|
@@ -267,6 +279,29 @@ def _make_controls(self) -> QWidget:
|
267 | 279 | button_load.setArrowType(Qt.ArrowType.DownArrow)
|
268 | 280 | button_load.setMenu(menu_load)
|
269 | 281 |
|
| 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 | + |
270 | 305 | button_refresh = QToolButton()
|
271 | 306 | button_refresh.setText("Refresh CSV")
|
272 | 307 | button_refresh.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
|
@@ -308,21 +343,81 @@ def _make_controls(self) -> QWidget:
|
308 | 343 | button_menu.addAction(animation_action)
|
309 | 344 | button_visuals.setMenu(button_menu)
|
310 | 345 |
|
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 |
| - |
318 | 346 | layout = QVBoxLayout()
|
319 | 347 | layout.addWidget(button_load)
|
| 348 | + layout.addWidget(button_load_config) |
320 | 349 | layout.addWidget(button_refresh)
|
321 | 350 | layout.addWidget(button_visuals)
|
322 | 351 | widget = QWidget()
|
323 | 352 | widget.setLayout(layout)
|
324 | 353 | return widget
|
325 | 354 |
|
| 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 | + |
326 | 421 | def _on_load_csv(self) -> None:
|
327 | 422 | csv_filenames, _ = QFileDialog.getOpenFileNames(None, "Select CSV Files", filter="CSV files (*.csv)")
|
328 | 423 | if not csv_filenames: # nothing selected, user canceled
|
@@ -476,21 +571,24 @@ def _do_save_config(self, filename: str) -> CsvLoaderStateModel:
|
476 | 571 | else: # save as abspath, would need .. access to get CSVs
|
477 | 572 | model.csv_files = [os.path.abspath(csv_filename) for csv_filename in self._csv_data_items.keys()]
|
478 | 573 |
|
| 574 | + self._loaded_config_abspath = os.path.abspath(filename) |
| 575 | + self._append_recent() |
| 576 | + |
479 | 577 | return model
|
480 | 578 |
|
481 | 579 | def _on_load_config(self) -> None:
|
482 | 580 | filename, _ = QFileDialog.getOpenFileName(None, "Load config", filter="YAML files (*.yml)")
|
483 | 581 | if not filename: # nothing selected, user canceled
|
484 | 582 | 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() |
485 | 587 | 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) |
487 | 590 | self._do_load_config(filename, model)
|
488 | 591 |
|
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 |
| - |
494 | 592 | def _do_load_config(self, filename: str, model: CsvLoaderStateModel) -> None:
|
495 | 593 | if model.csv_files is not None:
|
496 | 594 | missing_csv_files = []
|
@@ -521,3 +619,5 @@ def _do_load_config(self, filename: str, model: CsvLoaderStateModel) -> None:
|
521 | 619 | data_items = [(name, int_color(i), data_type) for i, (name, data_type) in enumerate(self._data_items.items())]
|
522 | 620 | self._set_data_items(data_items)
|
523 | 621 | self._set_data(data) # bulk update everything for performance
|
| 622 | + self._loaded_config_abspath = os.path.abspath(filename) |
| 623 | + self._append_recent() |
0 commit comments