toucan-plot 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. toucan_plot/__main__.py +4 -0
  2. toucan_plot/assets/adjustments-dark.svg +1 -0
  3. toucan_plot/assets/adjustments-light.svg +1 -0
  4. toucan_plot/assets/arrow-autofit-height-dark.svg +1 -0
  5. toucan_plot/assets/arrow-autofit-height-light.svg +1 -0
  6. toucan_plot/assets/arrow-narrow-left-dark.svg +1 -0
  7. toucan_plot/assets/arrow-narrow-left-light.svg +1 -0
  8. toucan_plot/assets/arrow-narrow-right-dark.svg +1 -0
  9. toucan_plot/assets/arrow-narrow-right-light.svg +1 -0
  10. toucan_plot/assets/device-floppy-dark.svg +1 -0
  11. toucan_plot/assets/device-floppy-light.svg +1 -0
  12. toucan_plot/assets/hand-stop-dark.svg +1 -0
  13. toucan_plot/assets/hand-stop-light.svg +1 -0
  14. toucan_plot/assets/home-dark.svg +1 -0
  15. toucan_plot/assets/home-light.svg +1 -0
  16. toucan_plot/assets/ico/toucan-plot.ico +0 -0
  17. toucan_plot/assets/layout-grid-add-dark.svg +1 -0
  18. toucan_plot/assets/layout-grid-add-light.svg +1 -0
  19. toucan_plot/assets/math-function-dark.svg +1 -0
  20. toucan_plot/assets/math-function-light.svg +1 -0
  21. toucan_plot/assets/ruler-measure-dark.svg +1 -0
  22. toucan_plot/assets/ruler-measure-light.svg +1 -0
  23. toucan_plot/assets/zoom-dark.svg +1 -0
  24. toucan_plot/assets/zoom-light.svg +1 -0
  25. toucan_plot/main.py +2047 -0
  26. toucan_plot/utils/__init__.py +14 -0
  27. toucan_plot/utils/loaders.py +219 -0
  28. toucan_plot/utils/styles.py +55 -0
  29. toucan_plot-0.2.0.dist-info/METADATA +16 -0
  30. toucan_plot-0.2.0.dist-info/RECORD +34 -0
  31. toucan_plot-0.2.0.dist-info/WHEEL +5 -0
  32. toucan_plot-0.2.0.dist-info/entry_points.txt +2 -0
  33. toucan_plot-0.2.0.dist-info/licenses/LICENSE +21 -0
  34. toucan_plot-0.2.0.dist-info/top_level.txt +1 -0
toucan_plot/main.py ADDED
@@ -0,0 +1,2047 @@
1
+ import os
2
+ import sys
3
+ import multiprocessing
4
+ import numpy as np
5
+ from PyQt6 import QtWidgets, QtCore, QtGui
6
+ import matplotlib as mpl
7
+ from matplotlib.figure import Figure
8
+ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
9
+ from matplotlib.backends.backend_qt import NavigationToolbar2QT as NavigationToolbar
10
+ import qdarktheme
11
+
12
+ from toucan_plot.utils import styles, csv_load_worker, can_log_load_worker, mf4_load_worker
13
+
14
+ LEGEND_POSITIONS = [
15
+ 'best', 'upper right', 'upper left', 'lower left', 'lower right',
16
+ 'center left', 'center right', 'upper center', 'lower center', 'center',
17
+ 'outside right', 'outside top',
18
+ 'outside upper right', 'outside lower right',
19
+ ]
20
+
21
+ class MainWindow(QtWidgets.QMainWindow):
22
+ def __init__(self):
23
+ super().__init__()
24
+ self.setWindowTitle('Toucan-Plot')
25
+ self.setWindowIcon(QtGui.QIcon(os.path.join(os.path.dirname(__file__), 'assets', 'ico', 'toucan-plot.ico')))
26
+ self.resize(900, 600)
27
+
28
+ # central widget and layout
29
+ central = QtWidgets.QWidget()
30
+ self.setCentralWidget(central)
31
+ layout = QtWidgets.QHBoxLayout(central)
32
+
33
+ # Matplotlib canvas
34
+ self.fig = Figure(figsize=(6, 4), dpi=100, facecolor='white')
35
+ self.canvas = FigureCanvas(self.fig)
36
+ self.ax = self.fig.add_subplot(111)
37
+ # canvas will be placed inside a vertical container together with the full NavigationToolbar
38
+
39
+ # Menu bar: add an action to create a new subplot from a chosen series
40
+ menubar = self.menuBar()
41
+ if menubar is not None:
42
+ file_menu = menubar.addMenu('File')
43
+ open_action = QtGui.QAction('Open', self)
44
+ open_action.setShortcut('Ctrl+O')
45
+ open_action.triggered.connect(self.open_file)
46
+ self._merge_action = QtGui.QAction('Merge', self)
47
+ self._merge_action.setShortcut('Ctrl+M')
48
+ self._merge_action.setEnabled(False)
49
+ self._merge_action.triggered.connect(self.merge_file)
50
+ exit_action = QtGui.QAction('Exit', self)
51
+ exit_action.triggered.connect(self.close)
52
+ if file_menu is not None:
53
+ file_menu.addAction(open_action)
54
+ file_menu.addAction(self._merge_action)
55
+ file_menu.addSeparator()
56
+ file_menu.addAction(exit_action)
57
+
58
+ style_menu = menubar.addMenu('Style')
59
+ style_action = QtGui.QAction('Customize plot style', self)
60
+ style_action.triggered.connect(self.show_plot_style_dialog)
61
+ if style_menu is not None:
62
+ style_menu.addAction(style_action)
63
+
64
+ # Theme submenu
65
+ theme_menu = style_menu.addMenu('Theme')
66
+ self._theme_actions = {}
67
+ theme_group = QtGui.QActionGroup(theme_menu)
68
+ for theme_name in ('dark', 'light'):
69
+ a = QtGui.QAction(theme_name.capitalize(), self)
70
+ a.setCheckable(True)
71
+ a.setChecked(theme_name == 'dark')
72
+ theme_group.addAction(a)
73
+ theme_menu.addAction(a)
74
+ self._theme_actions[theme_name] = a
75
+ def make_theme_handler(t):
76
+ def handler():
77
+ self._apply_theme(t)
78
+ return handler
79
+ a.triggered.connect(make_theme_handler(theme_name))
80
+
81
+ # Icon mappings for toolbar buttons (keyed by theme variant)
82
+ assets_dir = os.path.join(os.path.dirname(__file__), 'assets')
83
+ self._icons_buttons = {
84
+ "Home": { "dark": os.path.join(assets_dir, 'home-dark.svg'),
85
+ "light": os.path.join(assets_dir, 'home-light.svg') },
86
+ "Pan": { "dark": os.path.join(assets_dir, 'hand-stop-dark.svg'),
87
+ "light": os.path.join(assets_dir, 'hand-stop-light.svg') },
88
+ "Zoom": { "dark": os.path.join(assets_dir, 'zoom-dark.svg'),
89
+ "light": os.path.join(assets_dir, 'zoom-light.svg') },
90
+ "Customize": { "dark": os.path.join(assets_dir, 'adjustments-dark.svg'),
91
+ "light": os.path.join(assets_dir, 'adjustments-light.svg') },
92
+ "Back": { "dark": os.path.join(assets_dir, 'arrow-narrow-left-dark.svg'),
93
+ "light": os.path.join(assets_dir, 'arrow-narrow-left-light.svg') },
94
+ "Forward": { "dark": os.path.join(assets_dir, 'arrow-narrow-right-dark.svg'),
95
+ "light": os.path.join(assets_dir, 'arrow-narrow-right-light.svg') },
96
+ "Save": { "dark": os.path.join(assets_dir, 'device-floppy-dark.svg'),
97
+ "light": os.path.join(assets_dir, 'device-floppy-light.svg') },
98
+ }
99
+ self._extra_btn_icons = {
100
+ 'add_subplot': { "dark": os.path.join(assets_dir, 'layout-grid-add-dark.svg'),
101
+ "light": os.path.join(assets_dir, 'layout-grid-add-light.svg') },
102
+ 'fit_y': { "dark": os.path.join(assets_dir, 'arrow-autofit-height-dark.svg'),
103
+ "light": os.path.join(assets_dir, 'arrow-autofit-height-light.svg') },
104
+ 'measure': { "dark": os.path.join(assets_dir, 'ruler-measure-dark.svg'),
105
+ "light": os.path.join(assets_dir, 'ruler-measure-light.svg') },
106
+ 'x_axis': { "dark": os.path.join(assets_dir, 'math-function-dark.svg'),
107
+ "light": os.path.join(assets_dir, 'math-function-light.svg') },
108
+ }
109
+
110
+ # Current theme ("dark" or "light")
111
+ self._current_theme = 'light'
112
+
113
+ # Put canvas and the full Matplotlib navigation toolbar in a vertical widget
114
+ unwanted_buttons = ["Subplots"]
115
+ plot_widget = QtWidgets.QWidget()
116
+ self.nav_toolbar = NavigationToolbar(self.canvas, self, coordinates=False)
117
+ # Override edit_parameters so update_plot runs after the Customize dialog closes
118
+ _orig_edit_parameters = self.nav_toolbar.edit_parameters
119
+ def _custom_edit_parameters():
120
+ _orig_edit_parameters()
121
+ self.update_plot()
122
+ self.nav_toolbar.edit_parameters = _custom_edit_parameters
123
+ # Override the Home button to auto-fit all series data
124
+ for action in self.nav_toolbar.actions():
125
+ if action.text() in unwanted_buttons:
126
+ self.nav_toolbar.removeAction(action)
127
+ continue
128
+ if action.text() in self._icons_buttons:
129
+ icon_path = self._icons_buttons[action.text()][self._current_theme]
130
+ if os.path.exists(icon_path):
131
+ action.setIcon(QtGui.QIcon(icon_path))
132
+ if action.text() == 'Home':
133
+ action.triggered.disconnect()
134
+ action.triggered.connect(self._home_autoscale)
135
+
136
+ # Add subplot button next to the navigation toolbar
137
+ self._add_subplot_btn = QtWidgets.QToolButton()
138
+ self._add_subplot_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['add_subplot'][self._current_theme]))
139
+ self._add_subplot_btn.setIconSize(QtCore.QSize(24, 24))
140
+ self._add_subplot_btn.setToolTip('Add subplot')
141
+ self._add_subplot_btn.clicked.connect(self.show_series_selector)
142
+
143
+ # Fit Y axis button
144
+ self._fit_y_btn = QtWidgets.QToolButton()
145
+ self._fit_y_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['fit_y'][self._current_theme]))
146
+ self._fit_y_btn.setIconSize(QtCore.QSize(24, 24))
147
+ self._fit_y_btn.setToolTip('Fit Y data to axis (keep X range)')
148
+ self._fit_y_btn.clicked.connect(self._fit_y_autoscale)
149
+
150
+ # Measure lines toggle button
151
+ self.measure_btn = QtWidgets.QToolButton()
152
+ self.measure_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['measure'][self._current_theme]))
153
+ self.measure_btn.setIconSize(QtCore.QSize(24, 24))
154
+ self.measure_btn.setToolTip('Toggle measure cursors')
155
+ self.measure_btn.setCheckable(True)
156
+ self.measure_btn.toggled.connect(self._toggle_measure)
157
+
158
+ # X-axis selector button
159
+ self._x_axis_btn = QtWidgets.QToolButton()
160
+ self._x_axis_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['x_axis'][self._current_theme]))
161
+ self._x_axis_btn.setIconSize(QtCore.QSize(24, 24))
162
+ self._x_axis_btn.setToolTip('Select X axis series')
163
+ self._x_axis_btn.clicked.connect(self._show_x_axis_dialog)
164
+
165
+ self.nav_toolbar.setSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed)
166
+ self.nav_toolbar.setStyleSheet("QToolBar { background: transparent; border: none; }")
167
+
168
+ toolbar_layout = QtWidgets.QHBoxLayout()
169
+ toolbar_layout.setContentsMargins(0, 0, 0, 0)
170
+ toolbar_layout.addWidget(self.nav_toolbar)
171
+ toolbar_layout.addWidget(self._add_subplot_btn)
172
+ toolbar_layout.addWidget(self._fit_y_btn)
173
+ toolbar_layout.addWidget(self.measure_btn)
174
+ toolbar_layout.addWidget(self._x_axis_btn)
175
+ toolbar_layout.addStretch(1)
176
+
177
+ plot_vlayout = QtWidgets.QVBoxLayout(plot_widget)
178
+ plot_vlayout.setContentsMargins(0, 0, 0, 0)
179
+ plot_vlayout.addLayout(toolbar_layout)
180
+ plot_vlayout.addWidget(self.canvas)
181
+ layout.addWidget(plot_widget, stretch=3)
182
+
183
+ # Status bar for mouse coordinates
184
+ self._coord_label = QtWidgets.QLabel('')
185
+ self.statusBar().addWidget(self._coord_label)
186
+ self.canvas.mpl_connect('motion_notify_event', self._on_mouse_move)
187
+
188
+
189
+ # default frequency used for series that depend on f
190
+ self.default_freq = 1.0
191
+
192
+ # Default style used when creating/updating subplot configuration
193
+ self.default_plot_style = {
194
+ 'legend_show': True,
195
+ 'legend_pos': 'best',
196
+ 'legend_orient': 'vertical',
197
+ 'legend_fontsize': 12,
198
+ 'plot_mode': 'step',
199
+ 'grid_show': True,
200
+ 'marker': '',
201
+ }
202
+ self.available_plot_presets = {
203
+ 'Default': styles.set_default_style,
204
+ 'Style 1': styles.set_style1_style,
205
+ 'Style 2': styles.set_style2_style,
206
+ 'IEEE': styles.set_ieee_style,
207
+ }
208
+ self.selected_plot_preset = 'Default'
209
+ self.available_plot_presets[self.selected_plot_preset]()
210
+
211
+ # Prepare series definitions (5 series). x is shared.
212
+ self._x = np.linspace(0, 2 * np.pi, 400)
213
+ self._all_columns = {} # all loaded columns (name -> numpy array)
214
+ self._x_col_name = '' # current x-axis column name
215
+ self._skip_xlabel_capture = False # flag to skip xlabel capture during x-axis change
216
+ self._merged_mode = False # True when multiple files are merged
217
+ # Tracks loaded files: list of {'name': str, 'series_indices': [int, ...]}
218
+ self._loaded_files = []
219
+ # Each series is a callable that accepts x and freq and returns y
220
+ self.series_list = []
221
+ # Keep track of series per subplot: a list of lists. Each sublist contains indices into series_list.
222
+ # Start with no subplots (user requested the app start with none)
223
+ self.subplot_series = []
224
+ # Parallel structure to hold per-curve properties for each subplot.
225
+ # Each entry is a list of dicts matching the series in `subplot_series`.
226
+ self.subplot_series_props = []
227
+ # Per-subplot axes labels: a list of dicts with keys 'xlabel','ylabel_primary','ylabel_secondary'
228
+ self.subplot_axes_labels = []
229
+ # Per-subplot display config: legend and plot mode
230
+ # Each entry is a dict: {'legend_show': bool, 'legend_pos': str, 'legend_orient': str, 'plot_mode': str}
231
+ self.subplot_config = []
232
+
233
+ # Measure cursors state
234
+ self._measure_active = False
235
+ self._measure_lines = [] # list of axvline artists per axes
236
+ self._measure_x = [None, None] # x positions of the two cursors
237
+ self._measure_dragging = None # index (0 or 1) of cursor being dragged
238
+ self._measure_cids = [] # matplotlib event connection ids
239
+ self._measure_dialog = None
240
+ self._plotted_line_artists = [] # stored line artists per subplot for property capture
241
+ self._plotted_series_snapshot = [] # snapshot of subplot_series at last draw (for structure matching)
242
+
243
+ # Connect matplotlib double-clicks on the canvas to allow adding series to a clicked subplot
244
+ # Use mpl_connect to listen for mouse button press events; event.dblclick indicates a double-click.
245
+ self.canvas.mpl_connect('button_press_event', self.on_canvas_click)
246
+
247
+ # initial plot
248
+ self.update_plot()
249
+ self._apply_theme(self._current_theme)
250
+
251
+ def update_plot(self):
252
+ # Recreate subplots based on self.subplot_series
253
+ f = float(self.default_freq)
254
+ nsubs = len(self.subplot_series)
255
+
256
+ # Save current axis limits and labels before clearing
257
+ saved_limits = []
258
+ for i, ax in enumerate(getattr(self, 'current_axes', [])):
259
+ saved_limits.append((ax.get_xlim(), ax.get_ylim()))
260
+ # Capture labels that may have been edited via the navigation toolbar
261
+ if i < len(self.subplot_axes_labels):
262
+ current_xlabel = ax.get_xlabel()
263
+ current_ylabel = ax.get_ylabel()
264
+ # Only save xlabel if it's a user-set custom label (not the auto-generated x column name)
265
+ if current_xlabel and current_xlabel != self._x_col_name and not self._skip_xlabel_capture:
266
+ self.subplot_axes_labels[i]['xlabel'] = current_xlabel
267
+ if current_ylabel:
268
+ self.subplot_axes_labels[i]['ylabel_primary'] = current_ylabel
269
+ # Capture series properties (label, color, linewidth, linestyle, marker) from stored line artists
270
+ # Only capture if this subplot's structure hasn't changed since last draw
271
+ if (i < len(self._plotted_line_artists)
272
+ and i < len(self._plotted_series_snapshot)
273
+ and i < len(self.subplot_series)
274
+ and self._plotted_series_snapshot[i] == self.subplot_series[i]):
275
+ line_artists = self._plotted_line_artists[i]
276
+ if i < len(self.subplot_series_props):
277
+ for j, line in enumerate(line_artists):
278
+ if j < len(self.subplot_series_props[i]):
279
+ label = line.get_label()
280
+ if label and not label.startswith('_'):
281
+ self.subplot_series_props[i][j]['label'] = label
282
+ color = line.get_color()
283
+ if color:
284
+ self.subplot_series_props[i][j]['color'] = color
285
+ self.subplot_series_props[i][j]['linewidth'] = line.get_linewidth()
286
+ self.subplot_series_props[i][j]['linestyle'] = line.get_linestyle()
287
+ marker = line.get_marker()
288
+ if marker and marker != 'None':
289
+ self.subplot_series_props[i][j]['marker'] = marker
290
+ else:
291
+ self.subplot_series_props[i][j]['marker'] = ''
292
+
293
+ # Clear and create new axes array with shared x
294
+ self.fig.clear()
295
+ self._plotted_line_artists = []
296
+ self._plotted_series_snapshot = [list(s) for s in self.subplot_series]
297
+ if nsubs == 0:
298
+ # no subplots: clear figure and draw blank canvas
299
+ self.current_axes = []
300
+ self.canvas.draw()
301
+ return
302
+
303
+ if nsubs == 1:
304
+ axes = [self.fig.add_subplot(111)]
305
+ else:
306
+ axes = self.fig.subplots(nsubs, 1, sharex=True)
307
+ # ensure axes is a list
308
+ if not isinstance(axes, (list, np.ndarray)):
309
+ axes = [axes]
310
+ else:
311
+ axes = list(axes)
312
+
313
+ # Keep a reference to current axes so we can map mouse events to subplot indices
314
+ self.current_axes = axes
315
+
316
+ # Plot each subplot's series
317
+ for idx, series_indices in enumerate(self.subplot_series):
318
+ ax = axes[idx]
319
+ # retrieve axis labels for this subplot if any
320
+ ax_labels = {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''}
321
+ if idx < len(self.subplot_axes_labels):
322
+ ax_labels.update(self.subplot_axes_labels[idx])
323
+ props_list = []
324
+ if idx < len(self.subplot_series_props):
325
+ props_list = self.subplot_series_props[idx]
326
+ # Get subplot config
327
+ cfg = dict(self.default_plot_style)
328
+ if idx < len(self.subplot_config):
329
+ cfg.update(self.subplot_config[idx])
330
+ plot_fn_name = cfg.get('plot_mode', 'step')
331
+ axis_text_size = cfg.get('legend_fontsize', self.default_plot_style['legend_fontsize'])
332
+
333
+ # Plot each series assigned to this subplot
334
+ subplot_line_artists = []
335
+ for j, series_idx in enumerate(series_indices):
336
+ name, func, x_data = self.series_list[series_idx]
337
+ y = func(x_data, f)
338
+ # get curve properties if present
339
+ if j < len(props_list):
340
+ p = props_list[j]
341
+ color = p.get('color', '')
342
+ lw = p.get('linewidth', 1.5)
343
+ ls = p.get('linestyle', '')
344
+ marker = p.get('marker', '')
345
+ label = p.get('label', name)
346
+ else:
347
+ color = ''
348
+ lw = 1.5
349
+ ls = ''
350
+ marker = ''
351
+ label = name
352
+ # choose axis
353
+ yaxis = 1
354
+ if j < len(props_list):
355
+ yaxis = props_list[j].get('yaxis', 1)
356
+
357
+ if not marker:
358
+ marker = cfg.get('marker', '')
359
+ label = label.strip('_') # remove leading/trailing underscores for better math text parsing
360
+ draw_kwargs = dict(linewidth=lw, label=label)
361
+ if ls:
362
+ draw_kwargs['linestyle'] = ls
363
+ if marker:
364
+ draw_kwargs['marker'] = marker
365
+ if color:
366
+ draw_kwargs['color'] = color
367
+ target_ax = ax
368
+ if yaxis != 1:
369
+ target_ax = ax.twinx()
370
+ if plot_fn_name == 'step':
371
+ step_result = target_ax.step(x_data, y, where='post', **draw_kwargs)
372
+ subplot_line_artists.append(step_result[0])
373
+ else:
374
+ plot_result = target_ax.plot(x_data, y, **draw_kwargs)
375
+ subplot_line_artists.append(plot_result[0])
376
+ if yaxis != 1:
377
+ ax_ylabel_secondary = ax_labels.get('ylabel_secondary')
378
+ if ax_ylabel_secondary:
379
+ target_ax.set_ylabel(ax_ylabel_secondary, fontsize=axis_text_size)
380
+ target_ax.tick_params(axis='both', labelsize=axis_text_size)
381
+ self._plotted_line_artists.append(subplot_line_artists)
382
+ # Configure axis
383
+ ax_xlabel = ax_labels.get('xlabel')
384
+ ax_ylabel = ax_labels.get('ylabel_primary')
385
+ # X label only on the last subplot
386
+ if idx == nsubs - 1:
387
+ # Check if any subplot has a custom xlabel and use it
388
+ custom_xlabel = ''
389
+ for lbl in self.subplot_axes_labels:
390
+ if lbl.get('xlabel'):
391
+ custom_xlabel = lbl['xlabel']
392
+ if custom_xlabel:
393
+ ax.set_xlabel(custom_xlabel, fontsize=axis_text_size)
394
+ elif self._x_col_name:
395
+ ax.set_xlabel(self._x_col_name, fontsize=axis_text_size)
396
+ if ax_ylabel:
397
+ ax.set_ylabel(ax_ylabel, fontsize=axis_text_size)
398
+ ax.tick_params(axis='both', labelsize=axis_text_size)
399
+ if cfg.get('grid_show', True):
400
+ ax.grid(True, color='0.95')
401
+ # Legend
402
+ if cfg.get('legend_show', True) and len(series_indices) > 0:
403
+ ncol = len(series_indices) if cfg.get('legend_orient') == 'horizontal' else 1
404
+ legend_fs = cfg.get('legend_fontsize', self.default_plot_style['legend_fontsize'])
405
+ legend_pos = cfg.get('legend_pos', 'best')
406
+ outside_map = {
407
+ 'outside right': ('upper left', (1.02, 1)),
408
+ 'outside left': ('upper right', (-0.02, 1)),
409
+ 'outside top': ('lower left', (0, 1.02)),
410
+ 'outside upper right': ('upper left', (1.02, 1)),
411
+ 'outside lower right': ('lower left', (1.02, 0)),
412
+ }
413
+ if legend_pos in outside_map:
414
+ loc, bbox = outside_map[legend_pos]
415
+ ax.legend(loc=loc, bbox_to_anchor=bbox, borderaxespad=0, ncol=ncol, fontsize=legend_fs)
416
+ else:
417
+ ax.legend(loc=legend_pos, ncol=ncol, fontsize=legend_fs)
418
+
419
+ # Restore saved axis limits if available for this subplot
420
+ if idx < len(saved_limits):
421
+ ax.set_xlim(saved_limits[idx][0])
422
+ ax.set_ylim(saved_limits[idx][1])
423
+
424
+ self.fig.tight_layout()
425
+ # Redraw measure lines if active
426
+ if self._measure_active:
427
+ self._draw_measure_lines()
428
+ self._update_measure_dialog()
429
+ self.canvas.draw()
430
+
431
+ def on_randomize(self):
432
+ # Randomize button removed; keep method no-op in case called elsewhere
433
+ pass
434
+
435
+ def _on_mouse_move(self, event):
436
+ """Update status bar with mouse coordinates when hovering over a subplot."""
437
+ if event.inaxes is not None:
438
+ self._coord_label.setText(f'x = {event.xdata:.6g} y = {event.ydata:.6g}')
439
+ else:
440
+ self._coord_label.setText('')
441
+
442
+ def _home_autoscale(self, *args):
443
+ """Reset all subplot axes to fit all plotted data."""
444
+ for ax in getattr(self, 'current_axes', []):
445
+ ax.relim()
446
+ ax.autoscale(enable=True, axis='y', tight=False)
447
+ ax.autoscale(enable=True, axis='x', tight=True)
448
+ self.canvas.draw_idle()
449
+
450
+ def _show_x_axis_dialog(self):
451
+ """Open a dialog to select which column to use as the X axis."""
452
+ if not self._all_columns:
453
+ QtWidgets.QMessageBox.information(self, 'X Axis', 'No data loaded. Open a file first.')
454
+ return
455
+ if self._merged_mode:
456
+ QtWidgets.QMessageBox.information(self, 'X Axis', 'X axis selection is not available in merge mode. Each file uses its own X axis.')
457
+ return
458
+
459
+ dlg = QtWidgets.QDialog(self)
460
+ dlg.setWindowTitle('Select X Axis')
461
+ dlg.setMinimumWidth(300)
462
+ layout = QtWidgets.QFormLayout(dlg)
463
+
464
+ combo = QtWidgets.QComboBox()
465
+ combo.addItems(list(self._all_columns.keys()))
466
+ combo.setCurrentText(self._x_col_name)
467
+ layout.addRow('X axis column:', combo)
468
+
469
+ buttons = QtWidgets.QDialogButtonBox(
470
+ QtWidgets.QDialogButtonBox.StandardButton.Ok | QtWidgets.QDialogButtonBox.StandardButton.Cancel
471
+ )
472
+ layout.addRow(buttons)
473
+
474
+ def on_ok():
475
+ selected = combo.currentText()
476
+ if selected and selected != self._x_col_name:
477
+ self._change_x_axis(selected)
478
+ dlg.accept()
479
+
480
+ buttons.accepted.connect(on_ok)
481
+ buttons.rejected.connect(dlg.reject)
482
+ dlg.exec()
483
+
484
+ def _change_x_axis(self, col_name):
485
+ """Switch the x-axis to a different loaded column."""
486
+ if not col_name or col_name not in self._all_columns:
487
+ return
488
+ new_x = self._all_columns[col_name]
489
+
490
+ # Build a map from old series index -> series name
491
+ old_names = {i: name for i, (name, *_rest) in enumerate(self.series_list)}
492
+
493
+ self._x = new_x
494
+ self._x_col_name = col_name
495
+
496
+ # Rebuild series_list from all_columns, excluding the new x column
497
+ self.series_list.clear()
498
+ new_indices = []
499
+ for name, data in self._all_columns.items():
500
+ if name == col_name:
501
+ continue
502
+ idx = len(self.series_list)
503
+ self.series_list.append((name, lambda x, f, _d=data: _d, new_x))
504
+ new_indices.append(idx)
505
+
506
+ # Update loaded file tracking
507
+ if self._loaded_files:
508
+ self._loaded_files[0]['series_indices'] = new_indices
509
+
510
+ # Build a map from series name -> new index
511
+ new_index_by_name = {name: i for i, (name, *_rest) in enumerate(self.series_list)}
512
+
513
+ # Remap existing subplot assignments to the new indices
514
+ new_subplot_series = []
515
+ new_subplot_props = []
516
+ new_subplot_labels = []
517
+ new_subplot_config = []
518
+ for sub_idx, old_indices in enumerate(self.subplot_series):
519
+ remapped_indices = []
520
+ remapped_props = []
521
+ props = self.subplot_series_props[sub_idx] if sub_idx < len(self.subplot_series_props) else []
522
+ for j, old_idx in enumerate(old_indices):
523
+ name = old_names.get(old_idx)
524
+ if name and name in new_index_by_name:
525
+ remapped_indices.append(new_index_by_name[name])
526
+ if j < len(props):
527
+ remapped_props.append(props[j])
528
+ if remapped_indices:
529
+ new_subplot_series.append(remapped_indices)
530
+ new_subplot_props.append(remapped_props)
531
+ new_subplot_labels.append(
532
+ self.subplot_axes_labels[sub_idx] if sub_idx < len(self.subplot_axes_labels)
533
+ else {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''}
534
+ )
535
+ new_subplot_config.append(
536
+ self.subplot_config[sub_idx] if sub_idx < len(self.subplot_config)
537
+ else self._make_default_subplot_config()
538
+ )
539
+
540
+ self.subplot_series = new_subplot_series
541
+ self.subplot_series_props = new_subplot_props
542
+ self.subplot_axes_labels = new_subplot_labels
543
+ self.subplot_config = new_subplot_config
544
+
545
+ # Reset x labels on all subplots so the new x column name is used
546
+ for lbl in self.subplot_axes_labels:
547
+ lbl['xlabel'] = ''
548
+
549
+ self._skip_xlabel_capture = True
550
+ self.update_plot()
551
+ self._skip_xlabel_capture = False
552
+
553
+ def _fit_y_autoscale(self, *args):
554
+ """Fit Y data to axis while preserving the current X range."""
555
+ for ax in getattr(self, 'current_axes', []):
556
+ xlim = ax.get_xlim()
557
+ ax.relim()
558
+ ax.autoscale(enable=True, axis='y', tight=False)
559
+ ax.set_xlim(xlim)
560
+ self.canvas.draw_idle()
561
+
562
+ def _apply_theme(self, theme):
563
+ """Switch between 'dark' and 'light' themes."""
564
+ self._current_theme = theme
565
+ qdarktheme.setup_theme(theme)
566
+
567
+ # Update navigation toolbar icons
568
+ for action in self.nav_toolbar.actions():
569
+ if action.text() in self._icons_buttons:
570
+ icon_path = self._icons_buttons[action.text()][theme]
571
+ if os.path.exists(icon_path):
572
+ action.setIcon(QtGui.QIcon(icon_path))
573
+
574
+ # Update extra toolbar button icons
575
+ self._add_subplot_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['add_subplot'][theme]))
576
+ self._fit_y_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['fit_y'][theme]))
577
+ self.measure_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['measure'][theme]))
578
+ self._x_axis_btn.setIcon(QtGui.QIcon(self._extra_btn_icons['x_axis'][theme]))
579
+
580
+ # Update theme menu checkmarks
581
+ for t, a in self._theme_actions.items():
582
+ a.setChecked(t == theme)
583
+
584
+ def _make_default_subplot_config(self):
585
+ return dict(self.default_plot_style)
586
+
587
+ def show_plot_style_dialog(self):
588
+ dlg = QtWidgets.QDialog(self)
589
+ dlg.setWindowTitle('Customize Plot Style')
590
+ layout = QtWidgets.QFormLayout(dlg)
591
+
592
+ preset_combo = QtWidgets.QComboBox()
593
+ preset_combo.addItems(list(self.available_plot_presets.keys()))
594
+ preset_combo.setCurrentText(self.selected_plot_preset)
595
+
596
+ plot_mode_combo = QtWidgets.QComboBox()
597
+ plot_mode_combo.addItems(['step', 'plot'])
598
+ plot_mode_combo.setCurrentText(self.default_plot_style.get('plot_mode', 'step'))
599
+
600
+ grid_show_check = QtWidgets.QCheckBox('Show grid')
601
+ grid_show_check.setChecked(bool(self.default_plot_style.get('grid_show', True)))
602
+
603
+ marker_combo = QtWidgets.QComboBox()
604
+ marker_combo.addItems(['', 'o', 's', '^', 'v', 'x', '+', '*', '.', 'D', '|', '_'])
605
+ marker_combo.setCurrentText(self.default_plot_style.get('marker', ''))
606
+
607
+ legend_show_check = QtWidgets.QCheckBox('Show legend by default')
608
+ legend_show_check.setChecked(bool(self.default_plot_style.get('legend_show', True)))
609
+
610
+ legend_pos_combo = QtWidgets.QComboBox()
611
+ legend_pos_combo.addItems(LEGEND_POSITIONS)
612
+ legend_pos_combo.setCurrentText(self.default_plot_style.get('legend_pos', 'best'))
613
+
614
+ legend_orient_combo = QtWidgets.QComboBox()
615
+ legend_orient_combo.addItems(['vertical', 'horizontal'])
616
+ legend_orient_combo.setCurrentText(self.default_plot_style.get('legend_orient', 'vertical'))
617
+
618
+ text_size_spin = QtWidgets.QSpinBox()
619
+ text_size_spin.setRange(6, 24)
620
+ text_size_spin.setValue(int(self.default_plot_style.get('legend_fontsize', 12)))
621
+
622
+ apply_existing_check = QtWidgets.QCheckBox('Apply to existing subplots now')
623
+ apply_existing_check.setChecked(True)
624
+
625
+ layout.addRow('Preset from styles.py', preset_combo)
626
+ layout.addRow('Plot style', plot_mode_combo)
627
+ layout.addRow('Marker', marker_combo)
628
+ layout.addRow('Text size', text_size_spin)
629
+ layout.addRow('', grid_show_check)
630
+ layout.addRow('', legend_show_check)
631
+ layout.addRow('Legend position', legend_pos_combo)
632
+ layout.addRow('Legend orientation', legend_orient_combo)
633
+ layout.addRow('', apply_existing_check)
634
+
635
+ buttons = QtWidgets.QDialogButtonBox(
636
+ QtWidgets.QDialogButtonBox.StandardButton.Ok | QtWidgets.QDialogButtonBox.StandardButton.Cancel | QtWidgets.QDialogButtonBox.StandardButton.Apply
637
+ )
638
+ layout.addRow(buttons)
639
+
640
+ def apply_style():
641
+ selected_preset = preset_combo.currentText()
642
+ preset_fn = self.available_plot_presets.get(selected_preset)
643
+ if preset_fn is not None:
644
+ try:
645
+ preset_fn()
646
+ self.selected_plot_preset = selected_preset
647
+ except Exception as e:
648
+ QtWidgets.QMessageBox.warning(self, 'Style error', f'Failed to apply preset {selected_preset}:\n{e}')
649
+
650
+ if grid_show_check.isChecked() is True:
651
+ mpl.rcParams['axes.grid'] = True
652
+ mpl.rcParams['grid.alpha'] = 1.0
653
+ mpl.rcParams['grid.linewidth'] = 0.8
654
+ mpl.rcParams['lines.markersize'] = mpl.rcParams['lines.linewidth'] * 2.0
655
+
656
+ new_defaults = {
657
+ 'legend_show': legend_show_check.isChecked(),
658
+ 'legend_pos': legend_pos_combo.currentText(),
659
+ 'legend_orient': legend_orient_combo.currentText(),
660
+ 'legend_fontsize': int(text_size_spin.value()),
661
+ 'plot_mode': plot_mode_combo.currentText(),
662
+ 'grid_show': grid_show_check.isChecked(),
663
+ 'marker': marker_combo.currentText(),
664
+ }
665
+ self.default_plot_style = dict(new_defaults)
666
+
667
+ if apply_existing_check.isChecked():
668
+ for i in range(len(self.subplot_config)):
669
+ self.subplot_config[i] = dict(new_defaults)
670
+
671
+ self.update_plot()
672
+
673
+ def on_ok():
674
+ apply_style()
675
+ dlg.accept()
676
+
677
+ buttons.accepted.connect(on_ok)
678
+ buttons.rejected.connect(dlg.reject)
679
+ buttons.button(QtWidgets.QDialogButtonBox.StandardButton.Apply).clicked.connect(apply_style)
680
+ dlg.exec()
681
+
682
+ def open_files(self, paths):
683
+ """Load a list of file paths. The first data file is opened normally; any additional files are merged."""
684
+ if not paths:
685
+ return
686
+ blf_files = [p for p in paths if os.path.splitext(p)[1].lower() in ('.blf', '.trc', '.asc')]
687
+ dbc_files = [p for p in paths if os.path.splitext(p)[1].lower() == '.dbc']
688
+ csv_files = [p for p in paths if os.path.splitext(p)[1].lower() in ('.csv', '.smv')]
689
+ mf4_files = [p for p in paths if os.path.splitext(p)[1].lower() in ('.mf4', '.mf4z')]
690
+
691
+ first_loaded = False
692
+
693
+ if blf_files and dbc_files:
694
+ for blf_path in blf_files:
695
+ if not first_loaded:
696
+ self._load_blf(blf_path, dbc_files)
697
+ first_loaded = True
698
+ else:
699
+ self._merged_mode = True
700
+ self._merge_load_blf(blf_path, dbc_paths=dbc_files, prefix=os.path.basename(blf_path))
701
+
702
+ for path in csv_files:
703
+ if not first_loaded:
704
+ self._load_csv(path)
705
+ first_loaded = True
706
+ else:
707
+ self._merged_mode = True
708
+ self._merge_load_csv(path, os.path.basename(path))
709
+
710
+ for path in mf4_files:
711
+ if not first_loaded:
712
+ self._load_mf4(path)
713
+ first_loaded = True
714
+ else:
715
+ self._merged_mode = True
716
+ self._merge_load_mf4(path, os.path.basename(path))
717
+
718
+ if self._merged_mode and self.series_list:
719
+ file_names = [f['name'] for f in self._loaded_files]
720
+ self.update_plot()
721
+ self.setWindowTitle(f'Toucan-Plot — Merged: {", ".join(file_names)}')
722
+
723
+ def open_file(self):
724
+ file_filter = "All supported files (*.csv *.smv *.blf *.trc *.asc *.dbc *.mf4 *.mf4z);;CSV files (*.csv);;SMV files (*.smv);;MF4 files (*.mf4 *.mf4z);;CAN files (*.blf *.trc *.asc *.dbc);;All files (*)"
725
+ paths, _ = QtWidgets.QFileDialog.getOpenFileNames(self, 'Open File', '', file_filter)
726
+ self.open_files(paths)
727
+
728
+ def merge_file(self):
729
+ """Open additional files and append their series to the existing plot session."""
730
+ file_filter = "All supported files (*.csv *.smv *.blf *.trc *.asc *.dbc *.mf4 *.mf4z);;CSV files (*.csv);;SMV files (*.smv);;MF4 files (*.mf4 *.mf4z);;CAN files (*.blf *.trc *.asc *.dbc);;All files (*)"
731
+ paths, _ = QtWidgets.QFileDialog.getOpenFileNames(self, 'Merge Files', '', file_filter)
732
+ if not paths:
733
+ return
734
+
735
+ self._merged_mode = True
736
+
737
+ blf_files = [p for p in paths if os.path.splitext(p)[1].lower() in ('.blf', '.trc', '.asc')]
738
+ dbc_files = [p for p in paths if os.path.splitext(p)[1].lower() == '.dbc']
739
+ csv_files = [p for p in paths if os.path.splitext(p)[1].lower() in ('.csv', '.smv')]
740
+ mf4_files = [p for p in paths if os.path.splitext(p)[1].lower() in ('.mf4', '.mf4z')]
741
+
742
+ for path in csv_files:
743
+ basename = os.path.basename(path)
744
+ self._merge_load_csv(path, basename)
745
+
746
+ if blf_files and dbc_files:
747
+ for blf_path in blf_files:
748
+ basename = os.path.basename(blf_path)
749
+ self._merge_load_blf(blf_path, dbc_paths=dbc_files, prefix=basename)
750
+
751
+ for path in mf4_files:
752
+ basename = os.path.basename(path)
753
+ self._merge_load_mf4(path, basename)
754
+
755
+ if self.series_list:
756
+ file_names = [f['name'] for f in self._loaded_files]
757
+ self.update_plot()
758
+ self.setWindowTitle(f'Toucan-Plot — Merged: {", ".join(file_names)}')
759
+ self.show_series_selector()
760
+
761
+ def _merge_load_csv(self, path, prefix):
762
+ """Load a CSV/SMV file and append its series (prefixed) to the existing series_list."""
763
+ queue = multiprocessing.Queue()
764
+ proc = multiprocessing.Process(target=csv_load_worker, args=(path, queue))
765
+
766
+ progress = QtWidgets.QProgressDialog(f'Loading {os.path.basename(path)}...', None, 0, 100, self)
767
+ progress.setWindowTitle('Loading')
768
+ progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
769
+ progress.setMinimumDuration(0)
770
+ progress.setValue(0)
771
+
772
+ result_data = {}
773
+
774
+ def poll():
775
+ try:
776
+ while not queue.empty():
777
+ msg = queue.get_nowait()
778
+ if msg[0] == 'progress':
779
+ pct = msg[1]
780
+ label = msg[2] if len(msg) > 2 else ''
781
+ if pct >= 0:
782
+ progress.setValue(pct)
783
+ if label:
784
+ progress.setLabelText(label)
785
+ elif msg[0] == 'result':
786
+ result_data['data'] = msg[1]
787
+ progress.setValue(100)
788
+ progress.close()
789
+ elif msg[0] == 'error':
790
+ result_data['error'] = msg[1]
791
+ progress.close()
792
+ except Exception:
793
+ pass
794
+
795
+ timer = QtCore.QTimer(progress)
796
+ timer.timeout.connect(poll)
797
+ timer.start(50)
798
+ proc.start()
799
+ progress.exec()
800
+ timer.stop()
801
+ proc.join(timeout=10)
802
+
803
+ if 'error' in result_data:
804
+ QtWidgets.QMessageBox.warning(self, 'Error', result_data['error'])
805
+ return
806
+ if 'data' not in result_data:
807
+ return
808
+
809
+ x_col, all_columns = result_data['data']
810
+ x_array = all_columns[x_col]
811
+
812
+ # Use the first file's x as the default for measure cursor placement
813
+ if len(self.series_list) == 0:
814
+ self._x = x_array
815
+ self._x_col_name = x_col
816
+
817
+ # Append series with file prefix and track file
818
+ file_entry = {'name': prefix, 'series_indices': []}
819
+ for name in all_columns:
820
+ if name == x_col:
821
+ continue
822
+ data = all_columns[name]
823
+ prefixed_name = f'{prefix}: {name}'
824
+ idx = len(self.series_list)
825
+ self.series_list.append((prefixed_name, lambda x, f, _d=data: _d, x_array))
826
+ file_entry['series_indices'].append(idx)
827
+ self._loaded_files.append(file_entry)
828
+
829
+ def _merge_load_blf(self, blf_path, dbc_paths, prefix):
830
+ """Load a BLF/TRC/ASC file and append its series (prefixed) to the existing series_list."""
831
+ queue = multiprocessing.Queue()
832
+ proc = multiprocessing.Process(target=can_log_load_worker, args=(blf_path, dbc_paths, queue))
833
+
834
+ progress = QtWidgets.QProgressDialog(f'Loading {os.path.basename(blf_path)}...', None, 0, 100, self)
835
+ progress.setWindowTitle('Loading')
836
+ progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
837
+ progress.setMinimumDuration(0)
838
+ progress.setValue(0)
839
+
840
+ result_data = {}
841
+
842
+ def poll():
843
+ try:
844
+ while not queue.empty():
845
+ msg = queue.get_nowait()
846
+ if msg[0] == 'progress':
847
+ pct = msg[1]
848
+ label = msg[2] if len(msg) > 2 else ''
849
+ if pct >= 0:
850
+ progress.setValue(pct)
851
+ if label:
852
+ progress.setLabelText(label)
853
+ elif msg[0] == 'result':
854
+ result_data['data'] = msg[1]
855
+ progress.setValue(100)
856
+ progress.close()
857
+ elif msg[0] == 'error':
858
+ result_data['error'] = msg[1]
859
+ progress.close()
860
+ except Exception:
861
+ pass
862
+
863
+ timer = QtCore.QTimer(progress)
864
+ timer.timeout.connect(poll)
865
+ timer.start(50)
866
+ proc.start()
867
+ progress.exec()
868
+ timer.stop()
869
+ proc.join(timeout=10)
870
+
871
+ if 'error' in result_data:
872
+ QtWidgets.QMessageBox.warning(self, 'Error', result_data['error'])
873
+ return
874
+ if 'data' not in result_data:
875
+ return
876
+
877
+ x_col, all_columns = result_data['data']
878
+ x_array = all_columns[x_col]
879
+
880
+ # Use the first file's x as the default for measure cursor placement
881
+ if len(self.series_list) == 0:
882
+ self._x = x_array
883
+ self._x_col_name = x_col
884
+
885
+ # Append series with file prefix and track file
886
+ file_entry = {'name': prefix, 'series_indices': []}
887
+ for name in all_columns:
888
+ if name == x_col:
889
+ continue
890
+ data = all_columns[name]
891
+ prefixed_name = f'{prefix}: {name}'
892
+ idx = len(self.series_list)
893
+ self.series_list.append((prefixed_name, lambda x, f, _d=data: _d, x_array))
894
+ file_entry['series_indices'].append(idx)
895
+ self._loaded_files.append(file_entry)
896
+
897
+ def _load_csv(self, path):
898
+ """Load a CSV/SMV file using a worker process with live progress."""
899
+ queue = multiprocessing.Queue()
900
+ proc = multiprocessing.Process(target=csv_load_worker, args=(path, queue))
901
+
902
+ progress = QtWidgets.QProgressDialog(f'Loading {os.path.basename(path)}...', None, 0, 100, self)
903
+ progress.setWindowTitle('Loading')
904
+ progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
905
+ progress.setMinimumDuration(0)
906
+ progress.setValue(0)
907
+
908
+ result_data = {}
909
+
910
+ def poll():
911
+ try:
912
+ while not queue.empty():
913
+ msg = queue.get_nowait()
914
+ if msg[0] == 'progress':
915
+ pct = msg[1]
916
+ label = msg[2] if len(msg) > 2 else ''
917
+ if pct >= 0:
918
+ progress.setValue(pct)
919
+ if label:
920
+ progress.setLabelText(label)
921
+ elif msg[0] == 'result':
922
+ result_data['data'] = msg[1]
923
+ progress.setValue(100)
924
+ progress.close()
925
+ elif msg[0] == 'error':
926
+ result_data['error'] = msg[1]
927
+ progress.close()
928
+ except Exception:
929
+ pass
930
+
931
+ timer = QtCore.QTimer(progress)
932
+ timer.timeout.connect(poll)
933
+ timer.start(50)
934
+ proc.start()
935
+ progress.exec()
936
+ timer.stop()
937
+ proc.join(timeout=10)
938
+
939
+ if 'error' in result_data:
940
+ QtWidgets.QMessageBox.warning(self, 'Error', result_data['error'])
941
+ return
942
+ if 'data' not in result_data:
943
+ return
944
+
945
+ x_col, all_columns = result_data['data']
946
+ x_array = all_columns[x_col]
947
+
948
+ self._x = x_array
949
+ self._all_columns = all_columns
950
+ self._x_col_name = x_col
951
+ self._merged_mode = False
952
+
953
+ # Clear existing series and subplots
954
+ self.series_list.clear()
955
+ self.subplot_series.clear()
956
+ self.subplot_series_props.clear()
957
+ self.subplot_axes_labels.clear()
958
+ self.subplot_config.clear()
959
+ self._loaded_files.clear()
960
+
961
+ # Build series_list from all_columns (lambdas can't cross process boundary)
962
+ basename = os.path.basename(path)
963
+ file_entry = {'name': basename, 'series_indices': []}
964
+ for name in all_columns:
965
+ if name == x_col:
966
+ continue
967
+ data = all_columns[name]
968
+ idx = len(self.series_list)
969
+ self.series_list.append((name, lambda x, f, _d=data: _d, x_array))
970
+ file_entry['series_indices'].append(idx)
971
+ self._loaded_files.append(file_entry)
972
+ self._merge_action.setEnabled(True)
973
+
974
+ self.update_plot()
975
+ self.setWindowTitle(f'Toucan-Plot — {basename}')
976
+ self.show_series_selector()
977
+
978
+ def _load_blf(self, blf_path, dbc_paths):
979
+ """Load a BLF file using a worker process with live progress."""
980
+ queue = multiprocessing.Queue()
981
+ proc = multiprocessing.Process(target=can_log_load_worker, args=(blf_path, dbc_paths, queue))
982
+
983
+ progress = QtWidgets.QProgressDialog(f'Loading {os.path.basename(blf_path)}...', None, 0, 100, self)
984
+ progress.setWindowTitle('Loading')
985
+ progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
986
+ progress.setMinimumDuration(0)
987
+ progress.setValue(0)
988
+
989
+ result_data = {}
990
+
991
+ def poll():
992
+ try:
993
+ while not queue.empty():
994
+ msg = queue.get_nowait()
995
+ if msg[0] == 'progress':
996
+ pct = msg[1]
997
+ label = msg[2] if len(msg) > 2 else ''
998
+ if pct >= 0:
999
+ progress.setValue(pct)
1000
+ if label:
1001
+ progress.setLabelText(label)
1002
+ elif msg[0] == 'result':
1003
+ result_data['data'] = msg[1]
1004
+ progress.setValue(100)
1005
+ progress.close()
1006
+ elif msg[0] == 'error':
1007
+ result_data['error'] = msg[1]
1008
+ progress.close()
1009
+ except Exception:
1010
+ pass
1011
+
1012
+ timer = QtCore.QTimer(progress)
1013
+ timer.timeout.connect(poll)
1014
+ timer.start(50)
1015
+ proc.start()
1016
+ progress.exec()
1017
+ timer.stop()
1018
+ proc.join(timeout=10)
1019
+
1020
+ if 'error' in result_data:
1021
+ QtWidgets.QMessageBox.warning(self, 'Error', result_data['error'])
1022
+ return
1023
+ if 'data' not in result_data:
1024
+ return
1025
+
1026
+ x_col, all_columns = result_data['data']
1027
+ x_array = all_columns[x_col]
1028
+
1029
+ self._x = x_array
1030
+ self._all_columns = all_columns
1031
+ self._x_col_name = x_col
1032
+ self._merged_mode = False
1033
+
1034
+ # Clear existing series and subplots
1035
+ self.series_list.clear()
1036
+ self.subplot_series.clear()
1037
+ self.subplot_series_props.clear()
1038
+ self.subplot_axes_labels.clear()
1039
+ self.subplot_config.clear()
1040
+ self._loaded_files.clear()
1041
+
1042
+ # Build series_list from all_columns (lambdas can't cross process boundary)
1043
+ basename = os.path.basename(blf_path)
1044
+ file_entry = {'name': basename, 'series_indices': []}
1045
+ for name in all_columns:
1046
+ if name == x_col:
1047
+ continue
1048
+ data = all_columns[name]
1049
+ idx = len(self.series_list)
1050
+ self.series_list.append((name, lambda x, f, _d=data: _d, x_array))
1051
+ file_entry['series_indices'].append(idx)
1052
+ self._loaded_files.append(file_entry)
1053
+ self._merge_action.setEnabled(True)
1054
+
1055
+ self.update_plot()
1056
+ self.setWindowTitle(f'Toucan-Plot — {basename}')
1057
+ self.show_series_selector()
1058
+
1059
+ def _load_mf4(self, path):
1060
+ """Load an MF4 file using a worker process with live progress."""
1061
+ queue = multiprocessing.Queue()
1062
+ proc = multiprocessing.Process(target=mf4_load_worker, args=(path, queue))
1063
+
1064
+ progress = QtWidgets.QProgressDialog(f'Loading {os.path.basename(path)}...', None, 0, 100, self)
1065
+ progress.setWindowTitle('Loading')
1066
+ progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
1067
+ progress.setMinimumDuration(0)
1068
+ progress.setValue(0)
1069
+
1070
+ result_data = {}
1071
+
1072
+ def poll():
1073
+ try:
1074
+ while not queue.empty():
1075
+ msg = queue.get_nowait()
1076
+ if msg[0] == 'progress':
1077
+ pct = msg[1]
1078
+ label = msg[2] if len(msg) > 2 else ''
1079
+ if pct >= 0:
1080
+ progress.setValue(pct)
1081
+ if label:
1082
+ progress.setLabelText(label)
1083
+ elif msg[0] == 'result':
1084
+ result_data['data'] = msg[1]
1085
+ progress.setValue(100)
1086
+ progress.close()
1087
+ elif msg[0] == 'error':
1088
+ result_data['error'] = msg[1]
1089
+ progress.close()
1090
+ except Exception:
1091
+ pass
1092
+
1093
+ timer = QtCore.QTimer(progress)
1094
+ timer.timeout.connect(poll)
1095
+ timer.start(50)
1096
+ proc.start()
1097
+ progress.exec()
1098
+ timer.stop()
1099
+ proc.join(timeout=10)
1100
+
1101
+ if 'error' in result_data:
1102
+ QtWidgets.QMessageBox.warning(self, 'Error', result_data['error'])
1103
+ return
1104
+ if 'data' not in result_data:
1105
+ return
1106
+
1107
+ x_col, all_columns = result_data['data']
1108
+ x_array = all_columns[x_col]
1109
+
1110
+ self._x = x_array
1111
+ self._all_columns = all_columns
1112
+ self._x_col_name = x_col
1113
+ self._merged_mode = False
1114
+
1115
+ # Clear existing series and subplots
1116
+ self.series_list.clear()
1117
+ self.subplot_series.clear()
1118
+ self.subplot_series_props.clear()
1119
+ self.subplot_axes_labels.clear()
1120
+ self.subplot_config.clear()
1121
+ self._loaded_files.clear()
1122
+
1123
+ # Build series_list from all_columns
1124
+ basename = os.path.basename(path)
1125
+ file_entry = {'name': basename, 'series_indices': []}
1126
+ for name in all_columns:
1127
+ if name == x_col:
1128
+ continue
1129
+ data = all_columns[name]
1130
+ idx = len(self.series_list)
1131
+ self.series_list.append((name, lambda x, f, _d=data: _d, x_array))
1132
+ file_entry['series_indices'].append(idx)
1133
+ self._loaded_files.append(file_entry)
1134
+ self._merge_action.setEnabled(True)
1135
+
1136
+ self.update_plot()
1137
+ self.setWindowTitle(f'Toucan-Plot — {basename}')
1138
+ self.show_series_selector()
1139
+
1140
+ def _merge_load_mf4(self, path, prefix):
1141
+ """Load an MF4 file and append its series (prefixed) to the existing series_list."""
1142
+ queue = multiprocessing.Queue()
1143
+ proc = multiprocessing.Process(target=mf4_load_worker, args=(path, queue))
1144
+
1145
+ progress = QtWidgets.QProgressDialog(f'Loading {os.path.basename(path)}...', None, 0, 100, self)
1146
+ progress.setWindowTitle('Loading')
1147
+ progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
1148
+ progress.setMinimumDuration(0)
1149
+ progress.setValue(0)
1150
+
1151
+ result_data = {}
1152
+
1153
+ def poll():
1154
+ try:
1155
+ while not queue.empty():
1156
+ msg = queue.get_nowait()
1157
+ if msg[0] == 'progress':
1158
+ pct = msg[1]
1159
+ label = msg[2] if len(msg) > 2 else ''
1160
+ if pct >= 0:
1161
+ progress.setValue(pct)
1162
+ if label:
1163
+ progress.setLabelText(label)
1164
+ elif msg[0] == 'result':
1165
+ result_data['data'] = msg[1]
1166
+ progress.setValue(100)
1167
+ progress.close()
1168
+ elif msg[0] == 'error':
1169
+ result_data['error'] = msg[1]
1170
+ progress.close()
1171
+ except Exception:
1172
+ pass
1173
+
1174
+ timer = QtCore.QTimer(progress)
1175
+ timer.timeout.connect(poll)
1176
+ timer.start(50)
1177
+ proc.start()
1178
+ progress.exec()
1179
+ timer.stop()
1180
+ proc.join(timeout=10)
1181
+
1182
+ if 'error' in result_data:
1183
+ QtWidgets.QMessageBox.warning(self, 'Error', result_data['error'])
1184
+ return
1185
+ if 'data' not in result_data:
1186
+ return
1187
+
1188
+ x_col, all_columns = result_data['data']
1189
+ x_array = all_columns[x_col]
1190
+
1191
+ if len(self.series_list) == 0:
1192
+ self._x = x_array
1193
+ self._x_col_name = x_col
1194
+
1195
+ file_entry = {'name': prefix, 'series_indices': []}
1196
+ for name in all_columns:
1197
+ if name == x_col:
1198
+ continue
1199
+ data = all_columns[name]
1200
+ prefixed_name = f'{prefix}: {name}'
1201
+ idx = len(self.series_list)
1202
+ self.series_list.append((prefixed_name, lambda x, f, _d=data: _d, x_array))
1203
+ file_entry['series_indices'].append(idx)
1204
+ self._loaded_files.append(file_entry)
1205
+
1206
+ # ---- Measure cursors ----
1207
+
1208
+ def _toggle_measure(self, checked: bool):
1209
+ if checked:
1210
+ self._measure_active = True
1211
+ # Place two initial cursors at 1/3 and 2/3 of the visible x range
1212
+ if len(self.current_axes) > 0:
1213
+ xmin, xmax = self.current_axes[0].get_xlim()
1214
+ span = xmax - xmin
1215
+ self._measure_x = [xmin + span * 0.33, xmin + span * 0.66]
1216
+ else:
1217
+ self._measure_x = [0.0, 1.0]
1218
+ self._draw_measure_lines()
1219
+ self._show_measure_dialog()
1220
+ # Connect mouse events
1221
+ cid1 = self.canvas.mpl_connect('button_press_event', self._measure_press)
1222
+ cid2 = self.canvas.mpl_connect('motion_notify_event', self._measure_move)
1223
+ cid3 = self.canvas.mpl_connect('button_release_event', self._measure_release)
1224
+ self._measure_cids = [cid1, cid2, cid3]
1225
+ else:
1226
+ self._measure_active = False
1227
+ # Disconnect events
1228
+ for cid in self._measure_cids:
1229
+ self.canvas.mpl_disconnect(cid)
1230
+ self._measure_cids.clear()
1231
+ self._remove_measure_lines()
1232
+ if self._measure_dialog is not None:
1233
+ self._measure_dialog.close()
1234
+ self._measure_dialog = None
1235
+ self.canvas.draw_idle()
1236
+
1237
+ def _draw_measure_lines(self):
1238
+ self._remove_measure_lines()
1239
+ self._measure_lines = []
1240
+ for ax in self.current_axes:
1241
+ lines_for_ax = []
1242
+ for i, xpos in enumerate(self._measure_x):
1243
+ color = 'red' if i == 0 else 'blue'
1244
+ line = ax.axvline(x=xpos, color=color, linestyle='--', linewidth=1.0, picker=5)
1245
+ lines_for_ax.append(line)
1246
+ self._measure_lines.append(lines_for_ax)
1247
+ self.canvas.draw_idle()
1248
+
1249
+ def _remove_measure_lines(self):
1250
+ for lines_for_ax in self._measure_lines:
1251
+ for line in lines_for_ax:
1252
+ try:
1253
+ line.remove()
1254
+ except Exception:
1255
+ pass
1256
+ self._measure_lines.clear()
1257
+
1258
+ def _measure_press(self, event):
1259
+ if event.inaxes is None or event.button != 1:
1260
+ return
1261
+ # Don't drag measure cursors when zoom or pan is active
1262
+ toolbar_mode = getattr(self.nav_toolbar, 'mode', '')
1263
+ if toolbar_mode:
1264
+ return
1265
+ # Find closest cursor
1266
+ best_idx = None
1267
+ best_dist = float('inf')
1268
+ for i, xpos in enumerate(self._measure_x):
1269
+ dist = abs(event.xdata - xpos)
1270
+ if dist < best_dist:
1271
+ best_dist = dist
1272
+ best_idx = i
1273
+ # Only start drag if close enough (within 2% of x range)
1274
+ if len(self.current_axes) > 0:
1275
+ xmin, xmax = self.current_axes[0].get_xlim()
1276
+ threshold = (xmax - xmin) * 0.02
1277
+ else:
1278
+ threshold = best_dist + 1
1279
+ if best_dist <= threshold:
1280
+ self._measure_dragging = best_idx
1281
+
1282
+ def _measure_move(self, event):
1283
+ if self._measure_dragging is None or event.xdata is None:
1284
+ return
1285
+ self._measure_x[self._measure_dragging] = event.xdata
1286
+ self._draw_measure_lines()
1287
+ self._update_measure_dialog()
1288
+
1289
+ def _measure_release(self, event):
1290
+ if self._measure_dragging is not None:
1291
+ self._measure_dragging = None
1292
+
1293
+ def _show_measure_dialog(self):
1294
+ if self._measure_dialog is not None:
1295
+ self._measure_dialog.close()
1296
+ dlg = QtWidgets.QDialog(self)
1297
+ dlg.setWindowTitle('Measure Values')
1298
+ dlg.setMinimumWidth(420)
1299
+ dlg.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, False)
1300
+ layout = QtWidgets.QVBoxLayout(dlg)
1301
+ self._measure_table = QtWidgets.QTableWidget()
1302
+ layout.addWidget(self._measure_table)
1303
+ dlg.setLayout(layout)
1304
+ self._measure_dialog = dlg
1305
+ dlg.show()
1306
+ self._update_measure_dialog()
1307
+
1308
+ def _update_measure_dialog(self):
1309
+ if self._measure_dialog is None or not self._measure_dialog.isVisible():
1310
+ return
1311
+ x1, x2 = self._measure_x
1312
+ f = float(self.default_freq)
1313
+ table = self._measure_table
1314
+ # Columns: Series | Cursor 1 (x1) | Cursor 2 (x2) | Delta
1315
+ table.setColumnCount(4)
1316
+ table.setHorizontalHeaderLabels([
1317
+ 'Series',
1318
+ f'Cursor 1 (x={x1:.4f})',
1319
+ f'Cursor 2 (x={x2:.4f})',
1320
+ 'Delta',
1321
+ ])
1322
+ # Collect all plotted series across subplots
1323
+ rows = []
1324
+ for subplot_indices in self.subplot_series:
1325
+ for series_idx in subplot_indices:
1326
+ name, func, x_data = self.series_list[series_idx]
1327
+ y_data = func(x_data, f)
1328
+ y1 = float(np.interp(x1, x_data, y_data))
1329
+ y2 = float(np.interp(x2, x_data, y_data))
1330
+ rows.append((name, y1, y2, y2 - y1))
1331
+ table.setRowCount(len(rows))
1332
+ for r, (name, y1, y2, delta) in enumerate(rows):
1333
+ table.setItem(r, 0, QtWidgets.QTableWidgetItem(name))
1334
+ table.setItem(r, 1, QtWidgets.QTableWidgetItem(f'{y1:.6f}'))
1335
+ table.setItem(r, 2, QtWidgets.QTableWidgetItem(f'{y2:.6f}'))
1336
+ table.setItem(r, 3, QtWidgets.QTableWidgetItem(f'{delta:.6f}'))
1337
+ table.resizeColumnsToContents()
1338
+
1339
+ def show_series_selector(self):
1340
+ """Open the two-pane series editor to create a new subplot.
1341
+
1342
+ The dialog shows Available series (left) and Plotted series (right).
1343
+ When OK is pressed the plotted list is appended as a new subplot.
1344
+ """
1345
+ def _on_accept(plotted_indices, props, axes_labels):
1346
+ if plotted_indices:
1347
+ self.subplot_series.append(list(plotted_indices))
1348
+ # store props (make a shallow copy)
1349
+ self.subplot_series_props.append([dict(p) for p in props])
1350
+ # store axes labels
1351
+ self.subplot_axes_labels.append(dict(axes_labels) if axes_labels is not None else {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''})
1352
+ self.subplot_config.append(self._make_default_subplot_config())
1353
+ self.update_plot()
1354
+
1355
+ # For a new subplot, initial plotted list is empty
1356
+ self._open_series_editor(initial_plotted=[], on_accept=_on_accept, initial_props=[], initial_axes={})
1357
+
1358
+ def _open_series_editor(self, initial_plotted, on_accept, initial_props=None, initial_axes=None):
1359
+ """Open a modal editor to select series for a subplot.
1360
+
1361
+ When multiple files are loaded, the available series panel shows one tab per file.
1362
+ The plotted series list on the right is always a single unified list.
1363
+
1364
+ initial_plotted: list of series indices that should appear on the right (plotted)
1365
+ on_accept: callable(plotted_indices_list, props_list, axes_labels) called when OK pressed
1366
+ initial_props: optional list of dicts describing properties for each plotted series (aligned)
1367
+ """
1368
+ if initial_props is None:
1369
+ initial_props = []
1370
+ if initial_axes is None:
1371
+ initial_axes = {}
1372
+
1373
+ dlg = QtWidgets.QDialog(self)
1374
+ dlg.setWindowTitle('Edit series for subplot')
1375
+ dlg.setModal(True)
1376
+ dlg.resize(700, 420)
1377
+
1378
+ main_layout = QtWidgets.QVBoxLayout(dlg)
1379
+
1380
+ # --- Series: two-list editor ---
1381
+ series_layout = QtWidgets.QHBoxLayout()
1382
+ main_layout.addLayout(series_layout)
1383
+
1384
+ # Build per-file tab structure for available series (left side)
1385
+ avail_layout = QtWidgets.QVBoxLayout()
1386
+ series_layout.addLayout(avail_layout)
1387
+ avail_label = QtWidgets.QLabel('Available series')
1388
+ avail_layout.addWidget(avail_label)
1389
+
1390
+ # Map from series index -> which file index it belongs to (for returning items)
1391
+ series_to_file = {}
1392
+ for file_idx, fentry in enumerate(self._loaded_files):
1393
+ for sidx in fentry['series_indices']:
1394
+ series_to_file[sidx] = file_idx
1395
+
1396
+ plotted_set = set(initial_plotted)
1397
+
1398
+ # Build file tabs (or single list if only one file)
1399
+ use_tabs = len(self._loaded_files) > 1
1400
+ avail_tabs = None
1401
+ avail_lists = [] # parallel to _loaded_files
1402
+ avail_filters = []
1403
+
1404
+ if use_tabs:
1405
+ avail_tabs = QtWidgets.QTabWidget()
1406
+ avail_layout.addWidget(avail_tabs)
1407
+ for file_idx, fentry in enumerate(self._loaded_files):
1408
+ tab_widget = QtWidgets.QWidget()
1409
+ tab_layout = QtWidgets.QVBoxLayout(tab_widget)
1410
+ tab_layout.setContentsMargins(2, 2, 2, 2)
1411
+ filt = QtWidgets.QLineEdit()
1412
+ filt.setPlaceholderText('Filter...')
1413
+ filt.setClearButtonEnabled(True)
1414
+ tab_layout.addWidget(filt)
1415
+ lst = QtWidgets.QListWidget()
1416
+ lst.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.MultiSelection)
1417
+ tab_layout.addWidget(lst)
1418
+ avail_tabs.addTab(tab_widget, fentry['name'])
1419
+ avail_lists.append(lst)
1420
+ avail_filters.append(filt)
1421
+
1422
+ # Populate this tab's list with the file's series (excluding already plotted)
1423
+ for sidx in fentry['series_indices']:
1424
+ name = self.series_list[sidx][0]
1425
+ item = QtWidgets.QListWidgetItem(name)
1426
+ item.setData(QtCore.Qt.ItemDataRole.UserRole, sidx)
1427
+ if sidx not in plotted_set:
1428
+ lst.addItem(item)
1429
+
1430
+ # Connect filter
1431
+ def make_filter_handler(list_widget):
1432
+ def handler(text):
1433
+ text_lower = text.lower()
1434
+ for i in range(list_widget.count()):
1435
+ it = list_widget.item(i)
1436
+ it.setHidden(text_lower not in it.text().lower())
1437
+ return handler
1438
+ filt.textChanged.connect(make_filter_handler(lst))
1439
+
1440
+ # Connect double-click to add
1441
+ def make_dblclick_handler(list_widget):
1442
+ def handler(item):
1443
+ item.setSelected(True)
1444
+ move_selected_from(list_widget)
1445
+ return handler
1446
+ lst.itemDoubleClicked.connect(make_dblclick_handler(lst))
1447
+ else:
1448
+ # Single file or no files — flat list as before
1449
+ avail_filter = QtWidgets.QLineEdit()
1450
+ avail_filter.setPlaceholderText('Filter...')
1451
+ avail_filter.setClearButtonEnabled(True)
1452
+ avail_layout.addWidget(avail_filter)
1453
+ avail_list = QtWidgets.QListWidget()
1454
+ avail_list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.MultiSelection)
1455
+ avail_layout.addWidget(avail_list)
1456
+ avail_lists.append(avail_list)
1457
+ avail_filters.append(avail_filter)
1458
+
1459
+ for idx, (name, *_rest) in enumerate(self.series_list):
1460
+ item = QtWidgets.QListWidgetItem(name)
1461
+ item.setData(QtCore.Qt.ItemDataRole.UserRole, idx)
1462
+ if idx not in plotted_set:
1463
+ avail_list.addItem(item)
1464
+
1465
+ def on_filter_changed(text):
1466
+ text_lower = text.lower()
1467
+ for i in range(avail_list.count()):
1468
+ it = avail_list.item(i)
1469
+ it.setHidden(text_lower not in it.text().lower())
1470
+ avail_filter.textChanged.connect(on_filter_changed)
1471
+
1472
+ # --- Expression evaluator ---
1473
+ # Track last selected series name across all available lists
1474
+ last_selected_name = {'value': ''}
1475
+
1476
+ def on_avail_selection_changed():
1477
+ """Track the last selected item from any available list."""
1478
+ for lst in avail_lists:
1479
+ selected = lst.selectedItems()
1480
+ if selected:
1481
+ last_selected_name['value'] = selected[-1].text()
1482
+
1483
+ for lst in avail_lists:
1484
+ lst.itemSelectionChanged.connect(on_avail_selection_changed)
1485
+
1486
+ copy_to_expr_btn = QtWidgets.QPushButton('Copy selected to expression')
1487
+ copy_to_expr_btn.setToolTip('Insert the last selected series name into the expression field')
1488
+ avail_layout.addWidget(copy_to_expr_btn)
1489
+
1490
+ expr_layout = QtWidgets.QHBoxLayout()
1491
+ avail_layout.addLayout(expr_layout)
1492
+ expr_field = QtWidgets.QLineEdit()
1493
+ expr_field.setPlaceholderText('e.g. sin("series_A") + "series_B" * 2')
1494
+ expr_field.setToolTip(
1495
+ 'Math expression using series names in "quotes".\n'
1496
+ 'Supported: +, -, *, /, **, abs(), sin(), cos(), tan(),\n'
1497
+ 'sqrt(), exp(), log(), pi, e'
1498
+ )
1499
+ expr_layout.addWidget(expr_field)
1500
+
1501
+ add_expr_btn = QtWidgets.QPushButton('Add')
1502
+ add_expr_btn.setToolTip('Evaluate expression and add as a new plotted series')
1503
+ expr_layout.addWidget(add_expr_btn)
1504
+
1505
+ def copy_selected_to_expr():
1506
+ name = last_selected_name['value']
1507
+ if name:
1508
+ current = expr_field.text()
1509
+ cursor_pos = expr_field.cursorPosition()
1510
+ # If the name already contains quotes, it's an expression — insert as-is
1511
+ if '"' in name:
1512
+ token = name
1513
+ else:
1514
+ token = '"' + name + '"'
1515
+ new_text = current[:cursor_pos] + token + current[cursor_pos:]
1516
+ expr_field.setText(new_text)
1517
+ expr_field.setCursorPosition(cursor_pos + len(token))
1518
+ expr_field.setFocus()
1519
+
1520
+ copy_to_expr_btn.clicked.connect(copy_selected_to_expr)
1521
+
1522
+ def add_expression():
1523
+ expr_text = expr_field.text().strip()
1524
+ if not expr_text:
1525
+ return
1526
+
1527
+ # Build a mapping of series name -> (data_array, x_array)
1528
+ series_map = {}
1529
+ for idx_s, (sname, sfunc, sx) in enumerate(self.series_list):
1530
+ y_data = sfunc(sx, float(self.default_freq))
1531
+ series_map[sname] = (y_data, sx)
1532
+
1533
+ # Replace "series_name" tokens with placeholder variable names
1534
+ import re
1535
+ token_pattern = re.compile(r'"([^"]+)"')
1536
+ tokens_found = token_pattern.findall(expr_text)
1537
+
1538
+ if not tokens_found:
1539
+ QtWidgets.QMessageBox.warning(dlg, 'Expression Error',
1540
+ 'No series references found.\nUse "series_name" to reference a series.')
1541
+ return
1542
+
1543
+ # Validate all referenced series exist
1544
+ for tok in tokens_found:
1545
+ if tok not in series_map:
1546
+ QtWidgets.QMessageBox.warning(dlg, 'Expression Error',
1547
+ f'Series not found: {tok}')
1548
+ return
1549
+
1550
+ # Determine x_data: use the x from the first referenced series
1551
+ first_ref = tokens_found[0]
1552
+ expr_x = series_map[first_ref][1]
1553
+
1554
+ # For series with different x-axes, interpolate onto the first one
1555
+ var_arrays = {}
1556
+ for i, tok in enumerate(tokens_found):
1557
+ y_data, tok_x = series_map[tok]
1558
+ var_name = f'_s{i}'
1559
+ if tok_x is expr_x or (len(tok_x) == len(expr_x) and np.allclose(tok_x, expr_x)):
1560
+ var_arrays[var_name] = y_data
1561
+ else:
1562
+ var_arrays[var_name] = np.interp(expr_x, tok_x, y_data)
1563
+
1564
+ # Build the evaluable expression by replacing tokens with variable names
1565
+ eval_expr = expr_text
1566
+ for i, tok in enumerate(tokens_found):
1567
+ eval_expr = eval_expr.replace('"' + tok + '"', f'_s{i}')
1568
+
1569
+ # Safe math namespace
1570
+ safe_ns = {
1571
+ '__builtins__': {},
1572
+ 'sin': np.sin, 'cos': np.cos, 'tan': np.tan,
1573
+ 'abs': np.abs, 'sqrt': np.sqrt, 'exp': np.exp,
1574
+ 'log': np.log, 'log10': np.log10,
1575
+ 'pi': np.pi, 'e': np.e,
1576
+ 'arcsin': np.arcsin, 'arccos': np.arccos, 'arctan': np.arctan,
1577
+ 'clip': np.clip, 'sign': np.sign,
1578
+ }
1579
+ safe_ns.update(var_arrays)
1580
+
1581
+ try:
1582
+ result = eval(eval_expr, safe_ns) # noqa: S307
1583
+ result = np.asarray(result, dtype=float)
1584
+ if result.shape != expr_x.shape:
1585
+ result = np.broadcast_to(result, expr_x.shape).copy()
1586
+ except Exception as exc:
1587
+ QtWidgets.QMessageBox.warning(dlg, 'Expression Error',
1588
+ f'Failed to evaluate expression:\n{exc}')
1589
+ return
1590
+
1591
+ # Create a new computed series and add to series_list
1592
+ expr_label = expr_text
1593
+ new_idx = len(self.series_list)
1594
+ self.series_list.append((expr_label, lambda x, f, _d=result: _d, expr_x))
1595
+
1596
+ # Track in loaded files (add to a virtual "Expressions" file entry)
1597
+ expr_file = None
1598
+ for fe in self._loaded_files:
1599
+ if fe['name'] == '(Expressions)':
1600
+ expr_file = fe
1601
+ break
1602
+ if expr_file is None:
1603
+ expr_file = {'name': '(Expressions)', 'series_indices': []}
1604
+ self._loaded_files.append(expr_file)
1605
+ expr_file['series_indices'].append(new_idx)
1606
+ series_to_file[new_idx] = self._loaded_files.index(expr_file)
1607
+
1608
+ # Also add to the tabs if using tabs (add a new tab if needed)
1609
+ if use_tabs and avail_tabs is not None:
1610
+ expr_tab_idx = None
1611
+ for ti in range(len(avail_lists)):
1612
+ if ti < len(self._loaded_files) and self._loaded_files[ti]['name'] == '(Expressions)':
1613
+ expr_tab_idx = ti
1614
+ break
1615
+ if expr_tab_idx is None or expr_tab_idx >= len(avail_lists):
1616
+ # Create a new tab for expressions
1617
+ tab_widget = QtWidgets.QWidget()
1618
+ tab_lay = QtWidgets.QVBoxLayout(tab_widget)
1619
+ tab_lay.setContentsMargins(2, 2, 2, 2)
1620
+ filt = QtWidgets.QLineEdit()
1621
+ filt.setPlaceholderText('Filter...')
1622
+ filt.setClearButtonEnabled(True)
1623
+ tab_lay.addWidget(filt)
1624
+ lst = QtWidgets.QListWidget()
1625
+ lst.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.MultiSelection)
1626
+ tab_lay.addWidget(lst)
1627
+ avail_tabs.addTab(tab_widget, '(Expressions)')
1628
+ avail_lists.append(lst)
1629
+ avail_filters.append(filt)
1630
+ filt.textChanged.connect(make_filter_handler(lst))
1631
+ lst.itemDoubleClicked.connect(make_dblclick_handler(lst))
1632
+ lst.itemSelectionChanged.connect(on_avail_selection_changed)
1633
+ expr_tab_idx = len(avail_lists) - 1
1634
+
1635
+ # Add directly to plotted list
1636
+ item = QtWidgets.QListWidgetItem(expr_label)
1637
+ item.setData(QtCore.Qt.ItemDataRole.UserRole, new_idx)
1638
+ plotted_list.addItem(item)
1639
+
1640
+ expr_field.clear()
1641
+
1642
+ add_expr_btn.clicked.connect(add_expression)
1643
+
1644
+ # Buttons between lists
1645
+ btns_layout = QtWidgets.QVBoxLayout()
1646
+ series_layout.addLayout(btns_layout)
1647
+ btns_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter)
1648
+ add_btn = QtWidgets.QPushButton('>>')
1649
+ remove_btn = QtWidgets.QPushButton('<<')
1650
+ add_all_btn = QtWidgets.QPushButton('All >>')
1651
+ remove_all_btn = QtWidgets.QPushButton('<< All')
1652
+ btns_layout.addWidget(add_btn)
1653
+ btns_layout.addWidget(remove_btn)
1654
+ btns_layout.addSpacing(10)
1655
+ btns_layout.addWidget(add_all_btn)
1656
+ btns_layout.addWidget(remove_all_btn)
1657
+
1658
+ # Plotted list (right)
1659
+ plotted_layout = QtWidgets.QVBoxLayout()
1660
+ series_layout.addLayout(plotted_layout)
1661
+ plotted_label = QtWidgets.QLabel('Plotted series')
1662
+ plotted_layout.addWidget(plotted_label)
1663
+ plotted_list = QtWidgets.QListWidget()
1664
+ plotted_list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection)
1665
+ plotted_layout.addWidget(plotted_list)
1666
+
1667
+ # Populate plotted list with initial_plotted items
1668
+ for sidx in initial_plotted:
1669
+ name = self.series_list[sidx][0]
1670
+ item = QtWidgets.QListWidgetItem(name)
1671
+ item.setData(QtCore.Qt.ItemDataRole.UserRole, sidx)
1672
+ plotted_list.addItem(item)
1673
+
1674
+ # --- Move helpers ---
1675
+ def get_active_avail_list():
1676
+ """Return the currently visible available list."""
1677
+ if use_tabs and avail_tabs is not None:
1678
+ idx = avail_tabs.currentIndex()
1679
+ if 0 <= idx < len(avail_lists):
1680
+ return avail_lists[idx]
1681
+ return avail_lists[0] if avail_lists else None
1682
+
1683
+ def move_selected_from(src: QtWidgets.QListWidget):
1684
+ """Move selected items from an available list to the plotted list."""
1685
+ items = src.selectedItems()
1686
+ for it in items:
1687
+ row = src.row(it)
1688
+ src.takeItem(row)
1689
+ plotted_list.addItem(it)
1690
+
1691
+ def _avail_list_has_index(lst, sidx):
1692
+ """Check if a QListWidget already contains an item with the given series index."""
1693
+ for i in range(lst.count()):
1694
+ it = lst.item(i)
1695
+ if it is not None and int(it.data(QtCore.Qt.ItemDataRole.UserRole)) == sidx:
1696
+ return True
1697
+ return False
1698
+
1699
+ def move_selected_to_avail():
1700
+ """Move selected items from plotted list back to their original file tab."""
1701
+ items = plotted_list.selectedItems()
1702
+ for it in items:
1703
+ row = plotted_list.row(it)
1704
+ plotted_list.takeItem(row)
1705
+ sidx = int(it.data(QtCore.Qt.ItemDataRole.UserRole))
1706
+ file_idx = series_to_file.get(sidx, 0)
1707
+ target = avail_lists[file_idx] if file_idx < len(avail_lists) else (avail_lists[0] if avail_lists else None)
1708
+ if target is not None and not _avail_list_has_index(target, sidx):
1709
+ target.addItem(it)
1710
+
1711
+ def move_all_to_plotted():
1712
+ """Move all items from the active available list to plotted."""
1713
+ src = get_active_avail_list()
1714
+ if src is None:
1715
+ return
1716
+ while src.count() > 0:
1717
+ it = src.takeItem(0)
1718
+ plotted_list.addItem(it)
1719
+
1720
+ def move_all_to_avail():
1721
+ """Move all items from plotted list back to their original file tabs."""
1722
+ while plotted_list.count() > 0:
1723
+ it = plotted_list.takeItem(0)
1724
+ sidx = int(it.data(QtCore.Qt.ItemDataRole.UserRole))
1725
+ file_idx = series_to_file.get(sidx, 0)
1726
+ target = avail_lists[file_idx] if file_idx < len(avail_lists) else (avail_lists[0] if avail_lists else None)
1727
+ if target is not None and not _avail_list_has_index(target, sidx):
1728
+ target.addItem(it)
1729
+
1730
+ add_btn.clicked.connect(lambda: move_selected_from(get_active_avail_list()) if get_active_avail_list() else None)
1731
+ remove_btn.clicked.connect(move_selected_to_avail)
1732
+ add_all_btn.clicked.connect(move_all_to_plotted)
1733
+ remove_all_btn.clicked.connect(move_all_to_avail)
1734
+
1735
+ if not use_tabs:
1736
+ # Single-list double-click handlers
1737
+ avail_lists[0].itemDoubleClicked.connect(lambda item: (item.setSelected(True), move_selected_from(avail_lists[0])))
1738
+
1739
+ def on_plotted_double_click(item):
1740
+ item.setSelected(True)
1741
+ move_selected_to_avail()
1742
+
1743
+ plotted_list.itemDoubleClicked.connect(on_plotted_double_click)
1744
+
1745
+ # Dialog buttons
1746
+ dlg_buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Ok | QtWidgets.QDialogButtonBox.StandardButton.Cancel)
1747
+ main_layout.addWidget(dlg_buttons)
1748
+
1749
+ def on_ok():
1750
+ # collect plotted indices in order
1751
+ plotted_indices = []
1752
+ for i in range(plotted_list.count()):
1753
+ it = plotted_list.item(i)
1754
+ idx = it.data(QtCore.Qt.ItemDataRole.UserRole)
1755
+ plotted_indices.append(int(idx))
1756
+ # Preserve existing props where available, create defaults for new series
1757
+ existing_props_map = {}
1758
+ for j, sidx in enumerate(initial_plotted):
1759
+ if j < len(initial_props):
1760
+ existing_props_map[sidx] = initial_props[j]
1761
+ props = []
1762
+ for sidx in plotted_indices:
1763
+ if sidx in existing_props_map:
1764
+ props.append(dict(existing_props_map[sidx]))
1765
+ else:
1766
+ name = self.series_list[sidx][0]
1767
+ props.append({'label': name, 'color': '', 'linewidth': 1.5, 'linestyle': '', 'marker': '', 'yaxis': 1})
1768
+ axes_labels = dict(initial_axes) if initial_axes else {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''}
1769
+ on_accept(plotted_indices, props, axes_labels)
1770
+ dlg.accept()
1771
+
1772
+ def on_cancel():
1773
+ dlg.reject()
1774
+
1775
+ dlg_buttons.accepted.connect(on_ok)
1776
+ dlg_buttons.rejected.connect(on_cancel)
1777
+
1778
+ dlg.exec()
1779
+
1780
+ def on_canvas_click(self, event):
1781
+ """Handle mouse clicks on the Matplotlib canvas. On double-click inside an axes, open series dialog to add a line to that subplot."""
1782
+ # event.dblclick is True for double clicks
1783
+ if event.inaxes is None:
1784
+ return
1785
+
1786
+ # Find which axes was clicked
1787
+ try:
1788
+ ax_index = self.current_axes.index(event.inaxes)
1789
+ except Exception:
1790
+ return
1791
+
1792
+ # Double-click: edit the subplot's series
1793
+ if getattr(event, 'dblclick', False):
1794
+ def _on_accept(plotted_indices, props, axes_labels):
1795
+ # Replace the subplot's series, properties and axes labels with the returned values
1796
+ if plotted_indices is not None:
1797
+ self.subplot_series[ax_index] = list(plotted_indices)
1798
+ # ensure props list stored
1799
+ if ax_index < len(self.subplot_series_props):
1800
+ self.subplot_series_props[ax_index] = [dict(p) for p in props]
1801
+ else:
1802
+ # extend props list accordingly
1803
+ while len(self.subplot_series_props) < ax_index:
1804
+ self.subplot_series_props.append([])
1805
+ self.subplot_series_props.insert(ax_index, [dict(p) for p in props])
1806
+ # store axes labels
1807
+ if ax_index < len(self.subplot_axes_labels):
1808
+ self.subplot_axes_labels[ax_index] = dict(axes_labels) if axes_labels is not None else {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''}
1809
+ else:
1810
+ while len(self.subplot_axes_labels) < ax_index:
1811
+ self.subplot_axes_labels.append({'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''})
1812
+ self.subplot_axes_labels.insert(ax_index, dict(axes_labels) if axes_labels is not None else {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''})
1813
+ self.update_plot()
1814
+
1815
+ # provide existing props if available
1816
+ existing_props = []
1817
+ if ax_index < len(self.subplot_series_props):
1818
+ existing_props = self.subplot_series_props[ax_index]
1819
+
1820
+ existing_axes = {}
1821
+ if ax_index < len(self.subplot_axes_labels):
1822
+ existing_axes = self.subplot_axes_labels[ax_index]
1823
+ self._open_series_editor(initial_plotted=self.subplot_series[ax_index], on_accept=_on_accept, initial_props=existing_props, initial_axes=existing_axes)
1824
+ return
1825
+
1826
+ # Right-click: show context menu for subplot actions
1827
+ # Matplotlib's event.guiEvent (for QTAgg) contains the underlying Qt event
1828
+ # But only if zoom/pan is not active
1829
+ if getattr(event, 'button', None) == 3:
1830
+ # Check if zoom or pan mode is active (compatible across Matplotlib versions)
1831
+ toolbar_mode = getattr(self.nav_toolbar, 'mode', '')
1832
+ actions = getattr(self.nav_toolbar, '_actions', {})
1833
+ pan_action = actions.get('pan') if isinstance(actions, dict) else None
1834
+ zoom_action = actions.get('zoom') if isinstance(actions, dict) else None
1835
+ pan_active = bool(pan_action.isChecked()) if pan_action is not None else False
1836
+ zoom_active = bool(zoom_action.isChecked()) if zoom_action is not None else False
1837
+ if toolbar_mode or pan_active or zoom_active:
1838
+ # Zoom or pan is active, don't show context menu
1839
+ return
1840
+
1841
+ # Build a QMenu with actions: Delete subplot, Add above, Add below
1842
+ menu = QtWidgets.QMenu(self)
1843
+
1844
+ delete_action = QtGui.QAction('Delete subplot', self)
1845
+ add_above_action = QtGui.QAction('Add subplot above', self)
1846
+ add_below_action = QtGui.QAction('Add subplot below', self)
1847
+
1848
+ menu.addAction(add_above_action)
1849
+ menu.addAction(add_below_action)
1850
+ menu.addSeparator()
1851
+
1852
+ # --- Legend submenu ---
1853
+ # ensure config exists for this subplot
1854
+ while len(self.subplot_config) <= ax_index:
1855
+ self.subplot_config.append(self._make_default_subplot_config())
1856
+ cur_cfg = self.subplot_config[ax_index]
1857
+
1858
+ legend_menu = menu.addMenu('Legend')
1859
+
1860
+ show_legend_action = QtGui.QAction('Show legend', self)
1861
+ show_legend_action.setCheckable(True)
1862
+ show_legend_action.setChecked(cur_cfg.get('legend_show', True))
1863
+ def toggle_legend(checked):
1864
+ self.subplot_config[ax_index]['legend_show'] = checked
1865
+ self.default_plot_style['legend_show'] = checked
1866
+ self.update_plot()
1867
+ show_legend_action.toggled.connect(toggle_legend)
1868
+ legend_menu.addAction(show_legend_action)
1869
+
1870
+ # Position submenu
1871
+ pos_menu = legend_menu.addMenu('Position')
1872
+ current_pos = cur_cfg.get('legend_pos', 'best')
1873
+ pos_group = QtGui.QActionGroup(pos_menu)
1874
+ for p in LEGEND_POSITIONS:
1875
+ a = QtGui.QAction(p, self)
1876
+ a.setCheckable(True)
1877
+ a.setChecked(p == current_pos)
1878
+ pos_group.addAction(a)
1879
+ pos_menu.addAction(a)
1880
+ def make_pos_handler(pos_val):
1881
+ def handler():
1882
+ self.subplot_config[ax_index]['legend_pos'] = pos_val
1883
+ self.default_plot_style['legend_pos'] = pos_val
1884
+ self.update_plot()
1885
+ return handler
1886
+ a.triggered.connect(make_pos_handler(p))
1887
+
1888
+ # Orientation submenu
1889
+ orient_menu = legend_menu.addMenu('Orientation')
1890
+ current_orient = cur_cfg.get('legend_orient', 'vertical')
1891
+ orient_group = QtGui.QActionGroup(orient_menu)
1892
+ for ori in ('vertical', 'horizontal'):
1893
+ a = QtGui.QAction(ori.capitalize(), self)
1894
+ a.setCheckable(True)
1895
+ a.setChecked(ori == current_orient)
1896
+ orient_group.addAction(a)
1897
+ orient_menu.addAction(a)
1898
+ def make_orient_handler(ori_val):
1899
+ def handler():
1900
+ self.subplot_config[ax_index]['legend_orient'] = ori_val
1901
+ self.default_plot_style['legend_orient'] = ori_val
1902
+ self.update_plot()
1903
+ return handler
1904
+ a.triggered.connect(make_orient_handler(ori))
1905
+
1906
+ # Text size submenu
1907
+ size_menu = legend_menu.addMenu('Text size')
1908
+ current_fontsize = cur_cfg.get('legend_fontsize', 12)
1909
+ size_group = QtGui.QActionGroup(size_menu)
1910
+ for sz in (6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 20):
1911
+ a = QtGui.QAction(str(sz), self)
1912
+ a.setCheckable(True)
1913
+ a.setChecked(sz == current_fontsize)
1914
+ size_group.addAction(a)
1915
+ size_menu.addAction(a)
1916
+ def make_size_handler(sz_val):
1917
+ def handler():
1918
+ self.subplot_config[ax_index]['legend_fontsize'] = sz_val
1919
+ self.default_plot_style['legend_fontsize'] = sz_val
1920
+ self.update_plot()
1921
+ return handler
1922
+ a.triggered.connect(make_size_handler(sz))
1923
+
1924
+ # --- Plot style submenu ---
1925
+ plot_style_line_menu = menu.addMenu('Plot style')
1926
+ current_mode = cur_cfg.get('plot_mode', 'step')
1927
+ mode_group = QtGui.QActionGroup(plot_style_line_menu)
1928
+ for mode in ('plot', 'step'):
1929
+ a = QtGui.QAction(mode.capitalize(), self)
1930
+ a.setCheckable(True)
1931
+ a.setChecked(mode == current_mode)
1932
+ mode_group.addAction(a)
1933
+ plot_style_line_menu.addAction(a)
1934
+ def make_mode_handler(mode_val):
1935
+ def handler():
1936
+ self.subplot_config[ax_index]['plot_mode'] = mode_val
1937
+ self.default_plot_style['plot_mode'] = mode_val
1938
+ self.update_plot()
1939
+ return handler
1940
+ a.triggered.connect(make_mode_handler(mode))
1941
+
1942
+ menu.addSeparator()
1943
+ menu.addAction(delete_action)
1944
+
1945
+ # Helper to get global position robustly across PyQt/Matplotlib versions
1946
+ pos = None
1947
+ ge = getattr(event, 'guiEvent', None)
1948
+ if ge is not None:
1949
+ # Prefer globalPosition (QPointF) if available, else globalPos(), else map from local position
1950
+ try:
1951
+ gp = ge.globalPosition()
1952
+ # QPointF -> QPoint
1953
+ pos = QtCore.QPoint(int(gp.x()), int(gp.y()))
1954
+ except Exception:
1955
+ try:
1956
+ gp = ge.globalPos()
1957
+ pos = gp
1958
+ except Exception:
1959
+ try:
1960
+ # fallback to local position on the widget
1961
+ lp = ge.position()
1962
+ pos = self.canvas.mapToGlobal(QtCore.QPoint(int(lp.x()), int(lp.y())))
1963
+ except Exception:
1964
+ pos = None
1965
+ if pos is None:
1966
+ # Final fallback: map matplotlib event display coords to global
1967
+ try:
1968
+ # event.x, event.y are display coords (floats); map directly
1969
+ pos = self.canvas.mapToGlobal(QtCore.QPoint(int(event.x), int(event.y)))
1970
+ except Exception:
1971
+ pos = None
1972
+
1973
+ # Action handlers
1974
+ def do_delete():
1975
+ # remove the subplot at ax_index
1976
+ if 0 <= ax_index < len(self.subplot_series):
1977
+ self.subplot_series.pop(ax_index)
1978
+ # also remove properties if present
1979
+ if ax_index < len(self.subplot_series_props):
1980
+ self.subplot_series_props.pop(ax_index)
1981
+ if ax_index < len(self.subplot_axes_labels):
1982
+ self.subplot_axes_labels.pop(ax_index)
1983
+ if ax_index < len(self.subplot_config):
1984
+ self.subplot_config.pop(ax_index)
1985
+ self.update_plot()
1986
+
1987
+ def do_add(insert_pos):
1988
+ # Open series editor to choose initial plotted series for new subplot
1989
+ def _on_accept(plotted_indices, props, axes_labels):
1990
+ if plotted_indices is None:
1991
+ return
1992
+ # Insert the new subplot at insert_pos
1993
+ self.subplot_series.insert(insert_pos, list(plotted_indices))
1994
+ # insert props
1995
+ self.subplot_series_props.insert(insert_pos, [dict(p) for p in props])
1996
+ # insert axes labels
1997
+ self.subplot_axes_labels.insert(insert_pos, dict(axes_labels) if axes_labels is not None else {'xlabel': '', 'ylabel_primary': '', 'ylabel_secondary': ''})
1998
+ self.subplot_config.insert(insert_pos, self._make_default_subplot_config())
1999
+ self.update_plot()
2000
+
2001
+ # Call editor
2002
+ self._open_series_editor(initial_plotted=[], on_accept=_on_accept, initial_props=[], initial_axes={})
2003
+
2004
+ delete_action.triggered.connect(do_delete)
2005
+ add_above_action.triggered.connect(lambda: do_add(ax_index))
2006
+ add_below_action.triggered.connect(lambda: do_add(ax_index + 1))
2007
+
2008
+ # Show the menu at the computed position
2009
+ if pos is not None:
2010
+ menu.exec(pos)
2011
+ else:
2012
+ menu.exec(self.mapToGlobal(self.rect().center()))
2013
+
2014
+
2015
+ def main():
2016
+ import argparse
2017
+ parser = argparse.ArgumentParser(
2018
+ prog='toucan-plot',
2019
+ description='Toucan-Plot — Interactive plotting tool for CSV, SMV, and CAN log (BLF/TRC/ASC) files.',
2020
+ epilog='BLF, TRC, and ASC files require at least one DBC file for signal decoding.\n'
2021
+ 'Example: toucan-plot log.blf signals.dbc',
2022
+ )
2023
+ parser.add_argument(
2024
+ 'files', nargs='*', metavar='FILE',
2025
+ help='Data files to open on startup.\nCSV and SMV files are loaded directly.\n'
2026
+ 'BLF, TRC, and ASC files must be provided together with one or more DBC files.',
2027
+ )
2028
+ args = parser.parse_args()
2029
+
2030
+ # Set AppUserModelID so Windows taskbar shows the correct icon
2031
+ try:
2032
+ import ctypes
2033
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('toucan-plot')
2034
+ except Exception:
2035
+ pass
2036
+ qdarktheme.enable_hi_dpi()
2037
+ app = QtWidgets.QApplication(sys.argv)
2038
+ qdarktheme.setup_theme('dark')
2039
+ win = MainWindow()
2040
+ win.show()
2041
+ if args.files:
2042
+ win.open_files(args.files)
2043
+ sys.exit(app.exec())
2044
+
2045
+
2046
+ if __name__ == '__main__':
2047
+ main()