triplot 1.1.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.
dscpanel/ui/window.py ADDED
@@ -0,0 +1,4129 @@
1
+ """The main window: the plot, the outliner, and every operator.
2
+
3
+ This is a program and not a panel inside one: a real main window with docks, a
4
+ status bar and a menu bar whose entries are all operators. Every action is
5
+ registered once in `core/ops.py`, so the menu, the shortcut and the F3 palette
6
+ can never disagree about what exists or when it is allowed - and a shortcut
7
+ claimed twice is a startup error rather than a pair of QActions that quietly
8
+ fire neither.
9
+
10
+ The window owns the undo stack, because undo is about the document and a
11
+ gesture in the plot is only one of the things that changes it. The plot hands
12
+ over what a gesture did (`transform_done`) rather than writing history itself.
13
+ """
14
+
15
+ import contextlib
16
+ import json
17
+ import math
18
+ import os
19
+ import re
20
+ import sys
21
+
22
+ from PySide6.QtCore import QByteArray, QPoint, QPointF, QRectF, Qt
23
+ from PySide6.QtGui import (QAction, QColor, QCursor, QImage, QKeySequence,
24
+ QPainter)
25
+ from PySide6.QtWidgets import (QApplication, QDialog,
26
+ QDockWidget, QFileDialog, QInputDialog, QLabel,
27
+ QMainWindow, QMenu, QMessageBox,
28
+ QStackedWidget, QTabWidget, QWidget)
29
+
30
+ from .. import branding
31
+ from ..core import (arrange, dtg, export, loader, measure, model, ops,
32
+ presets,
33
+ session, style, undo, units)
34
+ from ..core.chem import mirrored_layout
35
+ from ..core.log import LOGGER
36
+ from ..core import figure as figure_module
37
+ from .dialogs import (AnalysisSettings, ArrowSettings, AxisSettings,
38
+ CaptionSettings, FigureSettings, LabelSettings,
39
+ ExportDialog, ImageSettings, LegendSettings,
40
+ MoleculeSettings, NumberSettings,
41
+ OffsetMarkerSettings, PageSizeDialog,
42
+ PresetSaveDialog, RangeDialog, SampleSettings,
43
+ ScanSettings, install_basic_colours)
44
+ from . import appearance
45
+ from . import colour as colour_module
46
+ from .loading import Loader
47
+ from .outliner import Outliner
48
+ from .palette import MeasurePalette, OperatorPalette
49
+ from . import plot as plot_module
50
+ from .plot import PlotWidget, paper_palette
51
+ from .settings import SettingsDialog
52
+
53
+
54
+ class FigureTab(object):
55
+ """One open figure: its document, its plot, its undo history, and what
56
+ it was when last saved or opened. A tab of the window."""
57
+
58
+ def __init__(self, window, doc=None):
59
+ self.doc = doc if doc is not None else model.Document()
60
+ self.undo = undo.UndoStack(on_change=window._undo_changed)
61
+ self.plot = PlotWidget(self.doc)
62
+ #: The saved state, compared for "has anything changed".
63
+ self.clean_state = None
64
+ #: The analysis whose gizmos are up and its settings, if any.
65
+ self.editing = None
66
+ #: The live settings windows opened on this figure.
67
+ self.dialogs = []
68
+
69
+ def title(self):
70
+ return (os.path.basename(self.doc.path) if self.doc.path
71
+ else "untitled")
72
+
73
+
74
+ def parse_temperatures(text, unit):
75
+ """Typed temperatures in degC, or None when any is not one. Separated
76
+ by ", ", ";" or a space before the next number: a comma with no space
77
+ is a decimal point ("261,5")."""
78
+ import re as _re
79
+ parts = [p for p in _re.split(r"\s*;\s*|,\s+|\s+(?=[-+]?\d)",
80
+ str(text).strip()) if p.strip()]
81
+ out = []
82
+ for part in parts:
83
+ value = units.parse_temperature(part, unit)
84
+ if value is None:
85
+ return None
86
+ out.append(float(value))
87
+ return out or None
88
+
89
+
90
+ class MainWindow(QMainWindow):
91
+ """The figures as tabs, the outliner, and every operator.
92
+
93
+ Each tab is a `FigureTab` - a document with its own plot and undo
94
+ history - and `doc`, `plot` and `undo` are the CURRENT tab's. With no
95
+ tab open the window shows its blank background, and
96
+ a stand-in figure nobody sees keeps every operator answerable.
97
+ """
98
+
99
+ def __init__(self, parent=None):
100
+ QMainWindow.__init__(self, parent)
101
+ self.setWindowTitle(branding.window_title())
102
+ #: The open figures, in no particular order (the tab bar has it).
103
+ self._figures = []
104
+ #: Stands in while no tab is open; never shown.
105
+ self._blank = FigureTab(self)
106
+ self._figure = self._blank
107
+ # Dressed BEFORE any widget exists, so nothing is built in the
108
+ # system's light look and repainted a moment later.
109
+ appearance.apply(self.doc.theme)
110
+ # The colour picker's basic colours: the plotter's, in its order.
111
+ install_basic_colours()
112
+ #: The export dialog's last choice of colours: light, for a page.
113
+ self._export_light = True
114
+ self._tabs = QTabWidget(self)
115
+ self._tabs.setDocumentMode(True)
116
+ self._tabs.setTabsClosable(True)
117
+ self._tabs.setMovable(True)
118
+ self._tabs.tabCloseRequested.connect(self.close_tab)
119
+ self._tabs.currentChanged.connect(self._tab_changed)
120
+ self._empty = QLabel(
121
+ "No figure open.\n\nCtrl+N a new figure\nCtrl+O open TRIOS "
122
+ "files\nCtrl+Shift+O open a session\nCtrl+W close the "
123
+ "program", self)
124
+ self._empty.setAlignment(Qt.AlignCenter)
125
+ self._empty.setStyleSheet("color: #8a8a8a;")
126
+ self._stack = QStackedWidget(self)
127
+ self._stack.addWidget(self._tabs)
128
+ self._stack.addWidget(self._empty)
129
+ self.setCentralWidget(self._stack)
130
+ self.setAcceptDrops(True)
131
+ self.resize(*self.opening_size())
132
+
133
+ self.outliner = Outliner(self)
134
+ self.outliner.set_document(self.doc)
135
+ dock = QDockWidget("Outliner", self)
136
+ dock.setObjectName("outliner")
137
+ dock.setWidget(self.outliner)
138
+ self.addDockWidget(Qt.RightDockWidgetArea, dock)
139
+ self._outliner_dock = dock
140
+
141
+ self.readout = QLabel("")
142
+ self.mode_label = QLabel("")
143
+ self.mode_label.setStyleSheet("color: #6ea8ff; font-weight: bold;")
144
+ self.note = QLabel("")
145
+ self.note.setStyleSheet("color: #c8a45a;")
146
+ self.statusBar().addWidget(self.readout, 1)
147
+ self.statusBar().addPermanentWidget(self.mode_label)
148
+ self.statusBar().addPermanentWidget(self.note)
149
+
150
+ self.loader = Loader(self)
151
+ self.loader.loaded.connect(self._sample_loaded)
152
+ self.loader.failed.connect(self._sample_failed)
153
+ self.loader.progress.connect(self._loading_progress)
154
+
155
+ self.outliner.visibility_changed.connect(self._set_visible)
156
+ # A sweep across the outliner's boxes is ONE undo step.
157
+ self.outliner.sweep_started.connect(
158
+ lambda: self.undo.begin_group("show / hide"))
159
+ # A lambda: `self.undo` is the CURRENT tab's, looked up when it fires.
160
+ self.outliner.sweep_finished.connect(lambda: self.undo.end_group())
161
+ self.outliner.segment_toggled.connect(self.toggle_segment)
162
+ self.outliner.selection_picked.connect(self._outliner_selected)
163
+ self.outliner.activated_object.connect(self.edit_object)
164
+ self.outliner.menu_for.connect(
165
+ lambda obj, pos: self._context_menu(obj, pos))
166
+ # Label rows dropped on a scan (or the Decorators): parenting.
167
+ self.outliner.parent_requested.connect(
168
+ lambda labels, scan: self.parent_labels(labels, scan))
169
+ # A file's own box, its name (F2) and its place in the list.
170
+ self.outliner.file_toggled.connect(
171
+ lambda sample, on: self.show_sample(sample, on))
172
+ self.outliner.sample_renamed.connect(
173
+ lambda sample, name: self.rename_sample(sample, name))
174
+ self.outliner.samples_moved.connect(
175
+ lambda samples, gap: self.move_samples(samples, gap))
176
+
177
+ self.ops = ops.OperatorRegistry()
178
+ self._last_operator = ""
179
+ self._register_ops()
180
+ self._install_shortcuts()
181
+ self._build_menus()
182
+ # The first tab: a program always opens on a figure.
183
+ self.new_figure()
184
+
185
+ # ------------------------------------------------------------ the tabs
186
+ @property
187
+ def doc(self):
188
+ return self._figure.doc
189
+
190
+ @doc.setter
191
+ def doc(self, value):
192
+ self._figure.doc = value
193
+
194
+ @property
195
+ def plot(self):
196
+ return self._figure.plot
197
+
198
+ @property
199
+ def undo(self):
200
+ return self._figure.undo
201
+
202
+ @property
203
+ def _clean_state(self):
204
+ return self._figure.clean_state
205
+
206
+ @_clean_state.setter
207
+ def _clean_state(self, value):
208
+ self._figure.clean_state = value
209
+
210
+ @property
211
+ def _editing(self):
212
+ return self._figure.editing
213
+
214
+ @_editing.setter
215
+ def _editing(self, value):
216
+ self._figure.editing = value
217
+
218
+ @property
219
+ def _dialogs(self):
220
+ return self._figure.dialogs
221
+
222
+ @_dialogs.setter
223
+ def _dialogs(self, value):
224
+ self._figure.dialogs = value
225
+
226
+ def figures(self):
227
+ """The open figures, in tab order."""
228
+ return [self._figure_at(i) for i in range(self._tabs.count())]
229
+
230
+ def _figure_at(self, index):
231
+ page = self._tabs.widget(index)
232
+ for figure in self._figures:
233
+ if figure.plot is page:
234
+ return figure
235
+ return None
236
+
237
+ def _wire(self, plot):
238
+ """A figure's plot speaks to the window (only the shown one can)."""
239
+ plot.hovered.connect(self.readout.setText)
240
+ plot.mode_changed.connect(self.mode_label.setText)
241
+ plot.context_menu.connect(self._context_menu)
242
+ plot.transform_done.connect(self._transform_done)
243
+ plot.measure_ready.connect(self._measure_ready)
244
+ plot.view_committed.connect(self._view_committed)
245
+ plot.margin_done.connect(
246
+ lambda side, share: self.set_fit_margin(side, share))
247
+ plot.page_margin_done.connect(
248
+ lambda side, value: self.set_page_margin(side, value))
249
+ plot.page_margins_asked.connect(lambda: self.ask_page_margins())
250
+ plot.data_margins_asked.connect(lambda: self.ask_data_margins())
251
+ plot.selection_changed.connect(self._selection_changed)
252
+ plot.activated.connect(self.edit_object)
253
+ plot.page_size_asked.connect(self.ask_page_size)
254
+
255
+ def new_figure(self, doc=None):
256
+ """A new tab with an empty figure (or `doc`), made current."""
257
+ figure = FigureTab(self, doc)
258
+ self._wire(figure.plot)
259
+ self._figures.append(figure)
260
+ self._stack.setCurrentWidget(self._tabs)
261
+ index = self._tabs.addTab(figure.plot, figure.title())
262
+ self._tabs.setCurrentIndex(index)
263
+ self._switch_to(figure)
264
+ # Nothing to lose yet: from here on, a difference is a change.
265
+ self.mark_clean()
266
+ return figure
267
+
268
+ def ensure_figure(self):
269
+ """A tab to put things in: the current one, or a new one when the
270
+ window is showing its blank background."""
271
+ if self._figure is self._blank:
272
+ self.new_figure()
273
+ return self._figure
274
+
275
+ def _tab_changed(self, index):
276
+ figure = self._figure_at(index) if index >= 0 else None
277
+ self._switch_to(figure or self._blank)
278
+
279
+ def _switch_to(self, figure):
280
+ if figure is self._figure:
281
+ return
282
+ # The pop-ups belong to the figure being left: closing them now
283
+ # makes their undo step on ITS history, and none edits a figure
284
+ # that is not on screen.
285
+ for dialog in self.popups():
286
+ dialog.close()
287
+ if self.plot.measuring() is not None:
288
+ self.plot.end_measure()
289
+ self._figure = figure
290
+ self.outliner.set_document(self.doc)
291
+ self.refresh()
292
+
293
+ def close_tab(self, index=None):
294
+ """Close a tab (the current one), asking first when it has unsaved
295
+ changes. The last one closed leaves the window's blank background.
296
+ True when it closed."""
297
+ index = self._tabs.currentIndex() if index is None else int(index)
298
+ figure = self._figure_at(index) if index >= 0 else None
299
+ if figure is None:
300
+ return False
301
+ if figure is not self._figure:
302
+ self._tabs.setCurrentIndex(index)
303
+ if self.is_modified():
304
+ answer = self.ask_to_save()
305
+ if answer == "cancel" or (answer == "save"
306
+ and not self.save_session()):
307
+ return False
308
+ for dialog in self.popups():
309
+ dialog.close()
310
+ self._figures.remove(figure)
311
+ if not self._figures:
312
+ self._blank = FigureTab(self)
313
+ self._switch_to(self._blank)
314
+ self._stack.setCurrentWidget(self._empty)
315
+ self._tabs.removeTab(self._tabs.indexOf(figure.plot))
316
+ figure.plot.setParent(None)
317
+ figure.plot.deleteLater()
318
+ self._sync_title()
319
+ return True
320
+
321
+ def nativeEvent(self, event_type, message):
322
+ """Note which way a wheel or swipe really went, on Windows.
323
+
324
+ Qt reports every Alt+wheel as horizontal there (Alt is how a mouse
325
+ scrolls sideways), which would make Alt+Shift+swipe - moving the
326
+ page - unable to go up or down. The system's own message says:
327
+ WM_MOUSEWHEEL is vertical, WM_MOUSEHWHEEL horizontal. Never raises:
328
+ this runs for every message the window gets.
329
+ """
330
+ try:
331
+ if sys.platform == "win32" and bytes(event_type) \
332
+ == b"windows_generic_MSG":
333
+ import ctypes.wintypes
334
+ msg = ctypes.wintypes.MSG.from_address(int(message))
335
+ if msg.message == 0x020A:
336
+ PlotWidget.native_wheel = "vertical"
337
+ elif msg.message == 0x020E:
338
+ PlotWidget.native_wheel = "horizontal"
339
+ except Exception:
340
+ pass
341
+ return False, 0
342
+
343
+ # ------------------------------------------------ the window's layout
344
+ def save_layout(self):
345
+ """Where the window was and where its outliner was docked, kept
346
+ with the user's preferences for the next start."""
347
+ style.set_window_state({
348
+ "geometry": bytes(self.saveGeometry().toBase64()).decode("ascii"),
349
+ "docks": bytes(self.saveState().toBase64()).decode("ascii")})
350
+ try:
351
+ style.save_preferences()
352
+ except OSError:
353
+ pass
354
+
355
+ def restore_layout(self):
356
+ """Put the window where it was last time. False when there is no
357
+ last time (the caller then opens it maximized)."""
358
+ state = style.window_state()
359
+ if not state or not state.get("geometry"):
360
+ return False
361
+ self.restoreGeometry(QByteArray.fromBase64(
362
+ state["geometry"].encode("ascii")))
363
+ if state.get("docks"):
364
+ self.restoreState(QByteArray.fromBase64(
365
+ state["docks"].encode("ascii")))
366
+ return True
367
+
368
+ #: Wider than tall, because a DSC figure is, and small enough to sit
369
+ #: beside something else. Clamped to the screen, which is the trap the
370
+ #: PXRD window hit at 150% scaling.
371
+ PREFERRED_SIZE = (1080, 660)
372
+
373
+ def opening_size(self):
374
+ width, height = self.PREFERRED_SIZE
375
+ screen = self.screen()
376
+ if screen is not None:
377
+ available = screen.availableGeometry()
378
+ width = min(width, int(available.width() * 0.9))
379
+ height = min(height, int(available.height() * 0.85))
380
+ return max(640, width), max(420, height)
381
+
382
+ # --------------------------------------------------------------- refresh
383
+ def drawing_theme(self):
384
+ """The theme the FIGURE is drawn in: the document's, unless its
385
+ page colour is light on a dark theme (or dark on the light one) -
386
+ then the other family's, so the ink reads on the paper."""
387
+ theme = self.doc.theme
388
+ if not self.doc.background:
389
+ return theme
390
+ light_page = QColor(self.doc.background).lightnessF() > 0.6
391
+ if light_page and theme != plot_module.THEME_LIGHT:
392
+ return plot_module.THEME_LIGHT
393
+ if not light_page and theme == plot_module.THEME_LIGHT:
394
+ return plot_module.THEME_DARK
395
+ return theme
396
+
397
+ def set_background(self, colour):
398
+ """The page's colour (None: the theme's), one undo step."""
399
+ self.undo.set_props([(self.doc, "background", colour or None)],
400
+ "background")
401
+ self.refresh()
402
+
403
+ def ask_background(self):
404
+ colour = colour_module.get_colour(
405
+ self.plot.page_colour(), self, "Background")
406
+ if colour.isValid():
407
+ self.set_background(colour.name())
408
+
409
+ def refresh(self, keep_view=True):
410
+ plot_module.set_theme(self.drawing_theme(), accent=self.doc.theme)
411
+ # The windows around the plot wear its theme too: the outliner, the
412
+ # menus and the dialogs were light beside a dark plot.
413
+ appearance.apply(self.doc.theme)
414
+ self.plot.rebuild(keep_view=keep_view)
415
+ self.outliner.refill()
416
+ self._sync_title()
417
+
418
+ def _sync_title(self):
419
+ subject = os.path.basename(self.doc.path) if self.doc.path else ""
420
+ if self._figure is not self._blank and self.is_modified():
421
+ subject = (subject or "unsaved") + " *"
422
+ self.setWindowTitle(branding.window_title(subject))
423
+ index = self._tabs.indexOf(self.plot)
424
+ if index >= 0:
425
+ self._tabs.setTabText(index, subject or "untitled")
426
+ self._tabs.setTabToolTip(index, self.doc.path or "not saved")
427
+
428
+ # ------------------------------------------------------ unsaved changes
429
+ def _document_state(self):
430
+ """What saving now would write, as text - for "has anything changed".
431
+
432
+ Compared rather than counted: undoing back to the saved state is
433
+ clean again, a zoom (on the undo stack, not in the file) is not a
434
+ change, and what never touches the undo stack (a file opened, the
435
+ unit switched) is one.
436
+ """
437
+ # The plot's framing as it is NOW, without writing it anywhere: this
438
+ # runs on every title refresh, and writing it onto the document here
439
+ # wiped a just-opened session's saved view before it was restored.
440
+ state = session.to_state(self.doc)
441
+ state["view"] = session.view_to_state(self._current_view())
442
+ return json.dumps(state, sort_keys=True, default=str)
443
+
444
+ def _current_view(self):
445
+ """The plot's framing, or None where it is fitted."""
446
+ state = self.plot.view_state()
447
+ # The weight axis's range counts too: M on its row alone, then F
448
+ # once (which resets x first), leaves only y2 framed, and that was
449
+ # neither saved nor a change.
450
+ return (None if state["x"] is None and state["y"] is None
451
+ and state.get("y2") is None else dict(state))
452
+
453
+ def _sync_view(self):
454
+ """The framing on the document, where the session file finds it:
455
+ the plot owns the live view, the file keeps it."""
456
+ self.doc.view = self._current_view()
457
+
458
+ def mark_clean(self):
459
+ self._clean_state = self._document_state()
460
+ self._sync_title()
461
+
462
+ def is_modified(self):
463
+ clean = getattr(self, "_clean_state", None)
464
+ return clean is not None and self._document_state() != clean
465
+
466
+ def ask_to_save(self):
467
+ """"save", "discard" or "cancel", asked in a box. A method of its
468
+ own so a test can answer it without a modal event loop."""
469
+ name = (os.path.basename(self.doc.path) if self.doc.path
470
+ else "this figure")
471
+ box = QMessageBox(self)
472
+ box.setIcon(QMessageBox.Warning)
473
+ box.setWindowTitle(branding.APP_NAME)
474
+ box.setText("Save the changes to {} before closing?".format(name))
475
+ box.setInformativeText("Closing without saving loses them.")
476
+ save = box.addButton("Save", QMessageBox.AcceptRole)
477
+ discard = box.addButton("Close without saving",
478
+ QMessageBox.DestructiveRole)
479
+ cancel = box.addButton("Cancel", QMessageBox.RejectRole)
480
+ box.setDefaultButton(save)
481
+ box.setEscapeButton(cancel)
482
+ box.exec()
483
+ clicked = box.clickedButton()
484
+ if clicked is save:
485
+ return "save"
486
+ if clicked is discard:
487
+ return "discard"
488
+ return "cancel"
489
+
490
+ def closeEvent(self, event):
491
+ """Closing with unsaved changes asks first (a hotkey from muscle
492
+ memory must not take a project with it). Save, close without
493
+ saving, or stay; a save that is cancelled keeps the window open."""
494
+ for figure in self.figures():
495
+ if figure is not self._figure:
496
+ self._tabs.setCurrentWidget(figure.plot)
497
+ if self.is_modified():
498
+ answer = self.ask_to_save()
499
+ if answer == "cancel" or (answer == "save"
500
+ and not self.save_session()):
501
+ event.ignore()
502
+ return
503
+ self.save_layout()
504
+ QMainWindow.closeEvent(self, event)
505
+
506
+ def _undo_changed(self):
507
+ # Gizmos up for an analysis whose interval an undo just changed:
508
+ # put them where the analysis now says it was measured.
509
+ editing = self.plot.editing()
510
+ if editing is not None and len(editing.cursors()) == 2:
511
+ self.plot.measuring()["cursors"] = list(editing.cursors())
512
+ self.refresh()
513
+
514
+ def _selection_changed(self):
515
+ self.outliner.sync_selection()
516
+
517
+ def _outliner_selected(self):
518
+ self.plot.update()
519
+
520
+ def _set_visible(self, obj, visible):
521
+ with self._room_for_axes("show" if visible else "hide"):
522
+ self.undo.set_props([(obj, "visible", bool(visible))],
523
+ "show" if visible else "hide")
524
+
525
+ @contextlib.contextmanager
526
+ def _room_for_axes(self, label):
527
+ """Around anything that may SHOW or HIDE a curve: a y axis that
528
+ appears on a side of an EXACT figure whose margin cannot hold it (the
529
+ heat flow's, joining an SDT run's mass on the right) gets that margin
530
+ grown to fit, in the same undo step, and the room is GIVEN BACK when
531
+ that axis leaves the side again - unless the margin was set by hand in
532
+ between (`FigureLayout.grown`). A margin set small on purpose stays,
533
+ and says so (`overflow`)."""
534
+ plot = self.plot
535
+ before = {plot.axis_side(a) for a in plot.shown_y_axes()}
536
+ # Inside a group already open (an outliner sweep) it joins that one:
537
+ # closing it here would end the sweep's single step early.
538
+ opened = getattr(self.undo, "_group", None) is None
539
+ if opened:
540
+ self.undo.begin_group(label)
541
+ try:
542
+ yield
543
+ self._grow_margins(before)
544
+ finally:
545
+ if opened:
546
+ self.undo.end_group()
547
+
548
+ def _grow_margins(self, sides_before):
549
+ layout = self.doc.figure
550
+ plot = self.plot
551
+ if plot.layout_mode() != figure_module.MODE_SIZE:
552
+ return []
553
+ after = {plot.axis_side(a) for a in plot.shown_y_axes()}
554
+ new = after - set(sides_before)
555
+ gone = set(sides_before) - after
556
+ per = (figure_module.PER_INCH[layout.unit]
557
+ / figure_module.DESIGN_DPI)
558
+ grown = dict(layout.grown)
559
+ changes = []
560
+ for side, needed, have in plot.overflow():
561
+ if side not in new or needed <= have:
562
+ continue
563
+ value = round(math.ceil(needed * per / 0.05 - 1e-9) * 0.05, 4)
564
+ before = getattr(layout, "margin_" + side)
565
+ changes.append((layout, "margin_" + side, value))
566
+ grown[side] = [before, value]
567
+ given = []
568
+ # What is still drawn in each margin, in the layout's unit - never
569
+ # cut off by giving room back (`PlotWidget.least_page_margins`).
570
+ least = dict(zip(figure_module.MARGIN_SIDES,
571
+ plot.least_page_margins()))
572
+ for side in sorted(gone):
573
+ if side not in grown:
574
+ continue
575
+ before, value = grown.pop(side)
576
+ here = getattr(layout, "margin_" + side)
577
+ if abs(here - value) > 1e-6:
578
+ continue # set by hand since: it stays
579
+ back = max(before, least[side])
580
+ if back < here - 1e-9:
581
+ changes.append((layout, "margin_" + side, back))
582
+ given.append(side)
583
+ if grown != layout.grown:
584
+ changes.append((layout, "grown", grown))
585
+ if changes:
586
+ self.undo.set_props(changes, "room for the axis")
587
+ plot.fit_page()
588
+ grew = [c[1][len("margin_"):] for c in changes
589
+ if c[1].startswith("margin_")
590
+ and c[1][len("margin_"):] not in given]
591
+ if grew:
592
+ self.note.setText("{} margin grown to fit its axis".format(
593
+ " and ".join(grew).capitalize()))
594
+ elif given:
595
+ self.note.setText("{} margin given back".format(
596
+ " and ".join(given).capitalize()))
597
+ return changes
598
+
599
+ def tighten_page_margins(self):
600
+ """Every white margin of an exact figure down to what it holds -
601
+ its numbers and captions, to the last drawn pixel - and no less:
602
+ the white space goes, no plot element is cut off. The page
603
+ shrinks; the axes box keeps its size. One undo step."""
604
+ plot = self.plot
605
+ if plot.layout_mode() != figure_module.MODE_SIZE:
606
+ self.note.setText("Only an exact-size figure has margins of "
607
+ "its own")
608
+ return []
609
+ return self.set_page_margins(dict(zip(
610
+ figure_module.MARGIN_SIDES, plot.least_page_margins())),
611
+ "tighten the page margins")
612
+
613
+ def set_page_margin(self, side, value):
614
+ """One white margin of an exact figure, in its unit: what a blade
615
+ lets go of or is typed. The page grows or shrinks by the change
616
+ and the axes box keeps its size (pulled in, a blade cuts white
617
+ space off; out, it adds some). One undo step."""
618
+ return self.set_page_margins({side: value}, "page margin")
619
+
620
+ def set_page_margins(self, values, label="page margins"):
621
+ """White margins `{side: value}` (the layout's unit), the page
622
+ resized with them so the axes box stays; the room grown for an
623
+ axis on those sides is forgotten. One undo step."""
624
+ layout = self.doc.figure
625
+ changes = []
626
+ width, height = layout.width, layout.height
627
+ grown = dict(layout.grown)
628
+ for side, value in values.items():
629
+ value = round(max(0.0, float(value)), 4)
630
+ old = getattr(layout, "margin_" + side)
631
+ if abs(value - old) <= 1e-9:
632
+ continue
633
+ changes.append((layout, "margin_" + side, value))
634
+ if side in ("left", "right"):
635
+ width += value - old
636
+ else:
637
+ height += value - old
638
+ grown.pop(side, None)
639
+ if not changes:
640
+ return []
641
+ changes += [(layout, "width", round(width, 4)),
642
+ (layout, "height", round(height, 4))]
643
+ if grown != layout.grown:
644
+ changes.append((layout, "grown", grown))
645
+ self.undo.set_props(changes, label)
646
+ self.plot.fit_page()
647
+ self.refresh()
648
+ return changes
649
+
650
+ def page_margins_dialog(self):
651
+ """The page's white margins in numbers (a blade double-clicked),
652
+ built and not shown."""
653
+ from .dialogs import PageMarginsDialog
654
+ plot = self.plot
655
+ layout = self.doc.figure
656
+ return PageMarginsDialog(
657
+ dict((side, getattr(layout, "margin_" + side))
658
+ for side in figure_module.MARGIN_SIDES),
659
+ dict(zip(figure_module.MARGIN_SIDES, plot.least_page_margins())),
660
+ layout.unit, self)
661
+
662
+ def ask_page_margins(self):
663
+ if self.plot.layout_mode() != figure_module.MODE_SIZE:
664
+ return None
665
+ dialog = self.page_margins_dialog()
666
+ if not dialog.exec():
667
+ return None
668
+ return self.set_page_margins(dialog.values())
669
+
670
+ def data_margins_dialog(self):
671
+ """The data's margins inside the axes box in numbers (an arrow
672
+ double-clicked), built and not shown."""
673
+ from .dialogs import DataMarginsDialog
674
+ plot = self.plot
675
+ return DataMarginsDialog(
676
+ dict((side, plot.fit_pad(side)) for side in style.FIT_SIDES),
677
+ self)
678
+
679
+ def ask_data_margins(self):
680
+ dialog = self.data_margins_dialog()
681
+ if not dialog.exec():
682
+ return None
683
+ return self.set_fit_margins(dialog.values())
684
+
685
+ def set_fit_margins(self, values):
686
+ """The data's margins `{side: share}` for this figure, the axes
687
+ refitted and any lock of theirs dropped: one undo step."""
688
+ style_obj = self.doc.style
689
+ changes = [(style_obj, "fit_" + side, float(share))
690
+ for side, share in values.items()
691
+ if abs(float(share) - self.plot.fit_pad(side)) > 1e-9]
692
+ if not changes:
693
+ return []
694
+ self.undo.begin_group("fit margins")
695
+ try:
696
+ self.undo.set_props(changes, "fit margins")
697
+ for which, sides in (("x", ("left", "right")),
698
+ ("y", ("bottom", "top"))):
699
+ if not any(side in values for side in sides):
700
+ continue
701
+ for name in (("x",) if which == "x" else ("y", "y2")):
702
+ axis = self.doc.axes[name]
703
+ if axis.lock:
704
+ self.undo.set_props([(axis, "lock", None),
705
+ (axis, "lock_context", None)],
706
+ "fit margins")
707
+ self.plot.fit_axis(which)
708
+ finally:
709
+ self.undo.end_group()
710
+ self.refresh()
711
+ return changes
712
+
713
+ def _transform_done(self, changes, label):
714
+ self.undo.set_props(changes, label)
715
+
716
+ # ------------------------------------------------ files in the outliner
717
+ def show_sample(self, sample, on):
718
+ """A file's box: all its curves on the figure shown or hidden, one
719
+ undo step."""
720
+ scans = [s for s in self.doc.scans if s.sample is sample
721
+ and bool(s.visible) != bool(on)]
722
+ if not scans:
723
+ return
724
+ with self._room_for_axes("show" if on else "hide"):
725
+ self.undo.set_props([(s, "visible", bool(on)) for s in scans],
726
+ "show {}".format(sample.name) if on
727
+ else "hide {}".format(sample.name))
728
+
729
+ def rename_sample(self, sample, name):
730
+ """A file's name on the figure ("" or None: the file's own), one
731
+ undo step. Its curves' names, the legend and exports follow."""
732
+ title = str(name).strip() or None
733
+ if title == sample.file_name:
734
+ title = None
735
+ if title == sample.title:
736
+ self.outliner.refill()
737
+ return
738
+ self.undo.set_props([(sample, "title", title)], "rename a file")
739
+ self.refresh()
740
+
741
+ def move_samples(self, samples, gap):
742
+ """Files dragged to the gap before file number `gap` (the length:
743
+ the end), one undo step. The outliner's order is the stack's."""
744
+ doc = self.doc
745
+ block = [s for s in doc.samples if s in samples]
746
+ if not block:
747
+ return False
748
+ old = list(doc.samples)
749
+ rest = [s for s in old if s not in block]
750
+ before = sum(1 for s in old[:max(0, int(gap))] if s in block)
751
+ at = max(0, min(len(rest), int(gap) - before))
752
+ new = rest[:at] + block + rest[at:]
753
+ if new == old:
754
+ return False
755
+ self.undo.push(undo.CallCommand(
756
+ lambda: doc.set_sample_order(new),
757
+ lambda: doc.set_sample_order(old), "move files"))
758
+ self.refresh()
759
+ return True
760
+
761
+ def swappable(self):
762
+ """The two selected scans, when exactly two are, else None."""
763
+ scans = self.doc.selected_scans()
764
+ return tuple(scans) if len(scans) == 2 else None
765
+
766
+ def swap_selected(self):
767
+ """Swap the places of the two selected scans (F3, and a scan's menu
768
+ with exactly two selected): their offsets, and - of two files - the
769
+ files' places in the outliner, so S and "Stack evenly" keep the
770
+ swap. One undo step."""
771
+ pair = self.swappable()
772
+ if pair is None:
773
+ return False
774
+ a, b = pair
775
+ doc = self.doc
776
+ self.undo.begin_group("swap two scans")
777
+ try:
778
+ self.undo.set_props([(a, "offset", float(b.offset)),
779
+ (b, "offset", float(a.offset))], "swap")
780
+ if a.sample is not b.sample:
781
+ old = list(doc.samples)
782
+ new = list(old)
783
+ i, j = new.index(a.sample), new.index(b.sample)
784
+ new[i], new[j] = new[j], new[i]
785
+ self.undo.push(undo.CallCommand(
786
+ lambda: doc.set_sample_order(new),
787
+ lambda: doc.set_sample_order(old), "swap"))
788
+ finally:
789
+ self.undo.end_group()
790
+ self.refresh()
791
+ return True
792
+
793
+ def set_axis_side(self, axis, side):
794
+ """Put `axis` on `side` (its spine, numbers and caption), one undo
795
+ step. A y axis's side is the heat flow axis's `side`, which is the
796
+ MAIN side: the mass axis takes it when shown and the heat flow
797
+ goes opposite - so the axis that stores it is worked out here."""
798
+ doc = self.doc
799
+ if axis.which == "x":
800
+ if side not in ("bottom", "top") or axis.side == side:
801
+ return False
802
+ self._to_side([(axis, "side", side)], ("top", "bottom"))
803
+ return True
804
+ if side not in ("left", "right"):
805
+ return False
806
+ other = "right" if side == "left" else "left"
807
+ main = side
808
+ if axis.which == "y" and self.plot.mass_shown():
809
+ main = other # the heat flow is opposite
810
+ owner = doc.axes["y"]
811
+ if owner.side == main:
812
+ return False
813
+ self._to_side([(owner, "side", main)], ("left", "right"))
814
+ return True
815
+
816
+ def _to_side(self, changes, sides):
817
+ """Put an axis on its other side, one undo step. An EXACT figure's
818
+ two margins on those `sides` keep their WHITE space - what each had
819
+ beyond what it held - so the one the axis comes to grows to hold
820
+ it and the one it leaves gives the room back, at once: the page
821
+ grows or shrinks by the difference, the axes box keeps its size.
822
+ (The margins stayed as they were, and the numbers were cut off
823
+ until a blade was touched.)"""
824
+ plot = self.plot
825
+ exact = plot.layout_mode() == figure_module.MODE_SIZE
826
+ layout = self.doc.figure
827
+ white = {}
828
+ if exact:
829
+ least = dict(zip(figure_module.MARGIN_SIDES,
830
+ plot.least_page_margins()))
831
+ white = dict((side, max(0.0, getattr(layout, "margin_" + side)
832
+ - least[side])) for side in sides)
833
+ opened = getattr(self.undo, "_group", None) is None
834
+ if opened:
835
+ self.undo.begin_group("axis side")
836
+ try:
837
+ self.undo.set_props(changes, "axis side")
838
+ if exact:
839
+ least = dict(zip(figure_module.MARGIN_SIDES,
840
+ plot.least_page_margins()))
841
+ self.set_page_margins(dict(
842
+ (side, least[side] + white[side]) for side in sides),
843
+ "axis side")
844
+ finally:
845
+ if opened:
846
+ self.undo.end_group()
847
+ self.refresh()
848
+
849
+ def toggle_follow_zoom(self):
850
+ """F3: decorators move with the data when zoomed, or stay on the
851
+ page (the default). Every one stays where it is drawn now; one
852
+ undo step."""
853
+ doc = self.doc
854
+ on = not doc.follow_zoom
855
+ changes = [(doc, "follow_zoom", on)] + self.plot.reframed(on)
856
+ self.undo.set_props(changes, "decorators follow the zoom" if on
857
+ else "decorators stay on the page")
858
+ self.refresh()
859
+ self.note.setText("Decorators now {}".format(
860
+ "follow the zoom" if on else "stay where they are on the page"))
861
+ return on
862
+
863
+ def mirrorable(self):
864
+ """The selected pictures and structures."""
865
+ return [o for o in self.doc.selected()
866
+ if isinstance(o, (model.ImageArtist, model.MoleculeArtist))]
867
+
868
+ def mirror_selected(self, horizontal=True):
869
+ """Mirror the selected pictures and structures left-right (or top-
870
+ bottom), each about its own middle, one undo step. A structure's
871
+ wedges and hashes swap, so it is still the same molecule, drawn the
872
+ other way round."""
873
+ changes = []
874
+ for obj in self.mirrorable():
875
+ # Left-right ON SCREEN whatever it is turned by: mirrored along
876
+ # its own axis, turned the other way (a mirror times a turn is
877
+ # the opposite turn times the mirror).
878
+ angle = float(getattr(obj, "rotation", 0.0) or 0.0)
879
+ if angle:
880
+ changes.append((obj, "rotation", (-angle) % 360.0))
881
+ if isinstance(obj, model.ImageArtist):
882
+ name = "mirror_h" if horizontal else "mirror_v"
883
+ changes.append((obj, name, not getattr(obj, name)))
884
+ continue
885
+ atoms, bonds = mirrored_layout(obj.atoms, obj.bonds, horizontal)
886
+ changes += [(obj, "atoms", atoms), (obj, "bonds", bonds)]
887
+ if not changes:
888
+ return 0
889
+ self.undo.set_props(changes, "mirror")
890
+ self.refresh()
891
+ return len(changes)
892
+
893
+ def lock_framing(self, on=True):
894
+ """"Lock the current framing": every axis on show keeps the range
895
+ it shows now, and F returns there. Off, F fits the data again. One
896
+ undo step."""
897
+ plot = self.plot
898
+ plot.commit_view()
899
+ changes = []
900
+ for which in plot.shown_view_axes():
901
+ axis = self.doc.axes[which]
902
+ if on:
903
+ lo, hi = plot.view_of(which)
904
+ changes += [(axis, "lock", [float(lo), float(hi)]),
905
+ (axis, "lock_context", plot.axis_context(which))]
906
+ else:
907
+ changes += [(axis, "lock", None),
908
+ (axis, "lock_context", None)]
909
+ # Every axis, shown or not, loses its lock when unlocking.
910
+ if not on:
911
+ changes = [(a, name, None) for a in self.doc.axes.values()
912
+ for name in ("lock", "lock_context")]
913
+ self.undo.set_props(changes, "lock framing" if on
914
+ else "unlock framing")
915
+ plot.forget_fit()
916
+ self.refresh()
917
+
918
+ def lock_axis(self, axis, lo=None, hi=None):
919
+ """One axis's lock: at (lo, hi), at what it shows when those are
920
+ None, or off with `lo` False. One undo step."""
921
+ plot = self.plot
922
+ if lo is False:
923
+ changes = [(axis, "lock", None), (axis, "lock_context", None)]
924
+ else:
925
+ if lo is None or hi is None:
926
+ lo, hi = plot.view_of(axis.which)
927
+ if not hi > lo:
928
+ return False
929
+ changes = [(axis, "lock", [float(lo), float(hi)]),
930
+ (axis, "lock_context", plot.axis_context(axis.which))]
931
+ self.undo.set_props(changes, "lock axis")
932
+ self.refresh()
933
+ return True
934
+
935
+ def set_axis_range(self, axis, lo, hi):
936
+ """An axis's minimum and maximum, typed in its settings: the view,
937
+ as one step - and the lock too, if that axis is locked."""
938
+ if not hi > lo:
939
+ return False
940
+ if self.plot.lock_of(axis.which):
941
+ self.undo.begin_group("axis range")
942
+ try:
943
+ self.lock_axis(axis, lo, hi)
944
+ self.plot.set_axis_view(axis.which, lo, hi)
945
+ finally:
946
+ self.undo.end_group()
947
+ else:
948
+ self.plot.set_axis_view(axis.which, lo, hi)
949
+ self.refresh()
950
+ return True
951
+
952
+ def set_fit_margin(self, side, share):
953
+ """A margin arrow let go or typed: this figure's fit margin on
954
+ `side` (a share of the axis) and that axis refitted, ONE undo
955
+ step. A locked axis is unlocked by it: the margin is a fit's."""
956
+ self.undo.begin_group("fit margin")
957
+ try:
958
+ self.undo.set_props([(self.doc.style, "fit_" + side,
959
+ float(share))], "fit margin")
960
+ for which in (("x",) if side in ("left", "right")
961
+ else ("y", "y2")):
962
+ axis = self.doc.axes[which]
963
+ if axis.lock:
964
+ self.undo.set_props([(axis, "lock", None),
965
+ (axis, "lock_context", None)],
966
+ "fit margin")
967
+ self.plot.fit_axis("x" if side in ("left", "right") else "y")
968
+ finally:
969
+ self.undo.end_group()
970
+ self.refresh()
971
+
972
+ def _view_committed(self, before, after, label):
973
+ """A zoom, a pan or a fit, as one undo step.
974
+
975
+ Zooming in on a feature several times comes back out with Ctrl+Z,
976
+ one step at a time: in use, the framing is part of what a Ctrl+Z is
977
+ expected to walk back.
978
+ """
979
+ plot = self.plot
980
+ self.undo.push(undo.CallCommand(
981
+ lambda: plot.restore_view(after),
982
+ lambda: plot.restore_view(before), label))
983
+
984
+ def undo_step(self):
985
+ # A zoom whose fingers are still settling is finished first, so this
986
+ # undoes IT rather than the step before (see `PlotWidget.commit_view`).
987
+ self.plot.commit_view()
988
+ return self.undo.undo()
989
+
990
+ def redo_step(self):
991
+ self.plot.commit_view()
992
+ return self.undo.redo()
993
+
994
+ # ------------------------------------------------------------- operators
995
+ def _register_ops(self):
996
+ r = self.ops.register
997
+ any_scan = lambda c: bool(c.doc.scans)
998
+ selected = lambda c: bool(c.doc.selected())
999
+ scans_selected = lambda c: bool(c.doc.selected_scans())
1000
+ several = lambda c: len(c.doc.selected_scans()) >= 2
1001
+
1002
+ r("file.open", "Open TRIOS files...", lambda c: c.open_files(),
1003
+ category="File", key="Ctrl+O", shortcut="Ctrl+O",
1004
+ aliases=("import", "tri", "add files"))
1005
+ r("file.new", "New figure", lambda c: c.new_figure_op(),
1006
+ category="File", key="Ctrl+N", shortcut="Ctrl+N",
1007
+ aliases=("tab", "new tab", "empty"))
1008
+ r("file.session_open", "Open a session...",
1009
+ lambda c: c.open_session(), category="File", key="Ctrl+Shift+O",
1010
+ shortcut="Ctrl+Shift+O")
1011
+ r("file.session_save", "Save the session", lambda c: c.save_session(),
1012
+ category="File", key="Ctrl+S", shortcut="Ctrl+S", enabled=any_scan)
1013
+ r("file.session_save_as", "Save the session as...",
1014
+ lambda c: c.save_session(ask=True), category="File",
1015
+ key="Ctrl+Shift+S", shortcut="Ctrl+Shift+S", enabled=any_scan)
1016
+ r("file.export_image", "Export the figure...",
1017
+ lambda c: c.export_image(), category="File", key="Ctrl+E",
1018
+ shortcut="Ctrl+E", enabled=any_scan, aliases=("svg", "png", "save"))
1019
+ r("file.export_csv", "Export the curves as CSV...",
1020
+ lambda c: c.export_csv(), category="File", enabled=any_scan)
1021
+ r("file.export_driver", "Export as a DSC_Plotter.py driver...",
1022
+ lambda c: c.export_driver(), category="File", enabled=any_scan,
1023
+ aliases=("matplotlib", "script", "template"))
1024
+ # Ctrl+W closes the POP-UP in front while any is open, and the window
1025
+ # only when none is: muscle memory for "close this" must not take
1026
+ # the whole project with it.
1027
+ r("file.close", "Close the pop-up in front, the tab, or the window",
1028
+ lambda c: c.close_step(), category="File", key="Ctrl+W",
1029
+ shortcut="Ctrl+W", aliases=("quit", "exit"))
1030
+
1031
+ r("edit.copy", "Copy the selected labels",
1032
+ lambda c: c.copy_selected(), category="Edit", key="Ctrl+C",
1033
+ shortcut="Ctrl+C",
1034
+ enabled=lambda c: bool(c.copyable()),
1035
+ aliases=("duplicate", "clipboard", "label", "note"))
1036
+ r("edit.paste", "Paste (a picture, a SMILES, text)",
1037
+ lambda c: c.paste(), category="Edit", key="Ctrl+V",
1038
+ shortcut="Ctrl+V",
1039
+ aliases=("image", "picture", "structure", "smiles", "molecule",
1040
+ "clipboard", "insert", "text", "label"))
1041
+ r("edit.paste_text", "Paste as a text label",
1042
+ lambda c: c.paste(as_text=True), category="Edit",
1043
+ key="Ctrl+Alt+V", shortcut="Ctrl+Alt+V",
1044
+ aliases=("plain", "text", "label", "literal"))
1045
+ # Pictures and structures mirror: turning a skeletal structure is
1046
+ # not enough.
1047
+ r("object.mirror_h", "Mirror horizontally (left-right)",
1048
+ lambda c: c.mirror_selected(True), category="Transform",
1049
+ key="Ctrl+Shift+H", shortcut="Ctrl+Shift+H",
1050
+ enabled=lambda c: bool(c.mirrorable()),
1051
+ aliases=("flip", "mirror", "reflect", "structure", "picture"))
1052
+ r("object.mirror_v", "Mirror vertically (top-bottom)",
1053
+ lambda c: c.mirror_selected(False), category="Transform",
1054
+ key="Ctrl+Shift+V", shortcut="Ctrl+Shift+V",
1055
+ enabled=lambda c: bool(c.mirrorable()),
1056
+ aliases=("flip", "mirror", "reflect", "structure", "picture"))
1057
+ r("text.bigger", "Text bigger", lambda c: c.text_size_step(1.0),
1058
+ category="Object", key="Ctrl+Up", shortcut="Ctrl+Up",
1059
+ enabled=lambda c: any(getattr(o, "kind", "") in c.TEXT_SIZES
1060
+ for o in c.doc.selected()),
1061
+ aliases=("font size", "larger", "size up"))
1062
+ r("text.smaller", "Text smaller", lambda c: c.text_size_step(-1.0),
1063
+ category="Object", key="Ctrl+Down", shortcut="Ctrl+Down",
1064
+ enabled=lambda c: any(getattr(o, "kind", "") in c.TEXT_SIZES
1065
+ for o in c.doc.selected()),
1066
+ aliases=("font size", "size down"))
1067
+ r("view.fit_page", "Fit the page to the window",
1068
+ lambda c: c.plot.fit_page(), category="View", key="Alt+F",
1069
+ shortcut="Alt+F",
1070
+ aliases=("page", "zoom", "document", "whole figure"))
1071
+ r("edit.undo", "Undo", lambda c: c.undo_step(), category="Edit",
1072
+ key="Ctrl+Z", shortcut="Ctrl+Z", aliases=("zoom back", "back"),
1073
+ enabled=lambda c: (c.undo.can_undo()
1074
+ or c.plot._view_burst is not None))
1075
+ r("edit.redo", "Redo", lambda c: c.redo_step(), category="Edit",
1076
+ key="Ctrl+Y", shortcut="Ctrl+Y", extra_keys=("Ctrl+Shift+Z",),
1077
+ enabled=lambda c: c.undo.can_redo())
1078
+
1079
+ r("select.all", "Select everything", lambda c: c.select_all(True),
1080
+ category="Select", key="Ctrl+A", shortcut="Ctrl+A",
1081
+ enabled=any_scan)
1082
+ r("select.none", "Select nothing", lambda c: c.select_all(False),
1083
+ category="Select", key="Alt+A", shortcut="Alt+A", enabled=selected)
1084
+ r("select.invert", "Invert the selection", lambda c: c.invert_selection(),
1085
+ category="Select", key="Ctrl+I", shortcut="Ctrl+I", enabled=any_scan)
1086
+ r("select.same_sample", "Select every scan of this sample",
1087
+ lambda c: c.select_same_sample(), category="Select",
1088
+ enabled=scans_selected)
1089
+
1090
+ r("transform.grab", "Move the selection", lambda c: c.plot.start_grab(),
1091
+ category="Transform", key="G",
1092
+ shortcut="G, then a number, Enter (Esc cancels)",
1093
+ enabled=selected, aliases=("offset", "drag", "shift"))
1094
+ # Blender's S, for what is drawn on the figure and is not data: the
1095
+ # arrow, the legend, a label.
1096
+ r("transform.scale", "Scale the selection", lambda c: c.plot.start_scale(),
1097
+ category="Transform", key="S",
1098
+ shortcut="S, then move or type a factor, Enter (Esc cancels); "
1099
+ "with only scans selected, spreads them about y = 0",
1100
+ enabled=lambda c: (any(c.plot.scale_fields(o)
1101
+ for o in c.doc.selected())
1102
+ or c.spreadable()),
1103
+ aliases=("size", "bigger", "smaller", "resize", "grow", "spread",
1104
+ "spacing", "stack", "even"))
1105
+ # ChemDraw's alignment of what is drawn on the figure: edges to the
1106
+ # outermost one, centres to the middle of them all.
1107
+ for edge, letter, words in (
1108
+ ("left", "L", "left edges"), ("right", "R", "right edges"),
1109
+ ("top", "T", "top edges"), ("bottom", "B", "bottom edges"),
1110
+ ("centre", "C", "centres, side to side"),
1111
+ ("middle", "M", "middles, up and down")):
1112
+ key = "Ctrl+Shift+Alt+" + letter
1113
+ r("arrange.align_" + edge, "Align the artists' {}".format(words),
1114
+ (lambda e: lambda c: c.align_artists(e))(edge),
1115
+ category="Transform", key=key, shortcut=key,
1116
+ enabled=lambda c: len(c.selected_artists()) >= 2,
1117
+ aliases=("align", "line up", edge, "chemdraw"))
1118
+ # The stack order, per object: Page Up and Page Down, which every
1119
+ # keyboard layout has (brackets need AltGr on a German one).
1120
+ for how, key, words in (
1121
+ ("front", "Ctrl+Shift+PgUp", "Bring to front"),
1122
+ ("forward", "Ctrl+PgUp", "Bring forward"),
1123
+ ("backward", "Ctrl+PgDown", "Send backward"),
1124
+ ("back", "Ctrl+Shift+PgDown", "Send to back")):
1125
+ r("order." + how, words,
1126
+ (lambda h: lambda c: c.restack(h))(how), category="Object",
1127
+ key=key, shortcut=key,
1128
+ enabled=lambda c: bool(c.layered_selected()),
1129
+ aliases=("z order", "layer", "above", "below", "stack order",
1130
+ "on top", "behind"))
1131
+ r("arrange.stack", "Stack the selected scans evenly",
1132
+ lambda c: c.stack_selected(), category="Transform",
1133
+ enabled=several, aliases=("spread", "offset", "space out"))
1134
+ r("arrange.distribute", "Distribute the offsets evenly",
1135
+ lambda c: c.distribute_selected(), category="Transform",
1136
+ enabled=lambda c: len(c.doc.selected_scans()) >= 3)
1137
+ r("arrange.align", "Align the selection to the active scan",
1138
+ lambda c: c.align_selected(), category="Transform",
1139
+ enabled=several, aliases=("baseline", "overlay", "match"))
1140
+ # R: the selected scans' offsets back to zero; with a label or the
1141
+ # legend selected instead, Blender's rotate. Nothing selected,
1142
+ # nothing happens.
1143
+ r("arrange.reset", "Reset the offsets of the selected scans",
1144
+ lambda c: c.r_key(), category="Transform", key="R",
1145
+ shortcut="R, with scans selected",
1146
+ aliases=("unstack", "align to zero", "flatten"),
1147
+ enabled=lambda c: (any(s.offset for s in c.doc.selected_scans())
1148
+ or c.rotatable_selected()))
1149
+ r("transform.rotate", "Rotate the selection",
1150
+ lambda c: c.plot.start_rotate(), category="Transform",
1151
+ shortcut="R, with a label or the legend selected",
1152
+ enabled=lambda c: c.rotatable_selected(),
1153
+ aliases=("turn", "angle", "rotation", "tilt"))
1154
+
1155
+ # NO Return binding. A window-level QAction on Return fires BEFORE
1156
+ # the plot sees the key, so pressing Enter to confirm a measurement
1157
+ # opened the scan's settings instead. Double-click is the way in.
1158
+ r("object.settings", "Settings for the selection...",
1159
+ lambda c: c.edit_object(), category="Object",
1160
+ shortcut="double-click", enabled=selected)
1161
+ r("object.hide", "Hide the selection", lambda c: c.hide_selected(),
1162
+ category="Object", key="H", shortcut="H", enabled=selected)
1163
+ r("object.show_all", "Show everything", lambda c: c.show_all(),
1164
+ category="Object", key="Alt+H", shortcut="Alt+H",
1165
+ enabled=lambda c: any(not o.visible for o in c.doc.objects()))
1166
+ r("object.remove", "Remove the selection",
1167
+ lambda c: c.remove_selected(), category="Object", key="Delete",
1168
+ shortcut="Del",
1169
+ enabled=lambda c: bool(c.files_to_close() or c.doc.selected_scans()
1170
+ or [o for o in c.doc.selected()
1171
+ if isinstance(o, (model.Analysis,
1172
+ model.TextLabel,
1173
+ model.ImageArtist,
1174
+ model.MoleculeArtist))
1175
+ ]))
1176
+ r("object.remove_file", "Remove every scan of this file",
1177
+ lambda c: c.remove_selected_files(), category="Object",
1178
+ enabled=scans_selected, aliases=("close file", "unload"))
1179
+ r("object.colour", "Colour for the selection...",
1180
+ lambda c: c.colour_selected(), category="Object",
1181
+ enabled=scans_selected)
1182
+ r("arrange.swap", "Swap the places of the two selected scans",
1183
+ lambda c: c.swap_selected(), category="Transform",
1184
+ enabled=lambda c: c.swappable() is not None,
1185
+ aliases=("swap", "flip", "exchange", "order", "reorder"))
1186
+ r("object.colour_gradient", "Colour gradient on the selection...",
1187
+ lambda c: c.colour_gradient(), category="Object",
1188
+ enabled=lambda c: len(c.doc.selected_scans()) >= 2,
1189
+ aliases=("shades", "tints", "lighter", "darker", "palette",
1190
+ "series"))
1191
+ r("analysis.show", "Show every analysis of the selected scans",
1192
+ lambda c: c.set_analyses(True), category="Object",
1193
+ enabled=lambda c: any(s.analysis_objects
1194
+ for s in c.doc.selected_scans()),
1195
+ aliases=("onsets", "tg", "enthalpy", "annotations"))
1196
+ r("analysis.hide", "Hide every analysis of the selected scans",
1197
+ lambda c: c.set_analyses(False), category="Object",
1198
+ enabled=lambda c: any(a.visible for s in c.doc.selected_scans()
1199
+ for a in s.analysis_objects))
1200
+ r("sample.molar_mass", "Set the molar mass...",
1201
+ lambda c: c.ask_molar_mass(), category="Object", key="Shift+M",
1202
+ shortcut="Shift+M", enabled=scans_selected,
1203
+ aliases=("M", "g/mol", "per mole"))
1204
+
1205
+ r("figure.tighten_margins", "Tighten the page margins",
1206
+ lambda c: c.tighten_page_margins(), category="View",
1207
+ aliases=("margins", "white space", "crop", "trim", "page",
1208
+ "tight layout"),
1209
+ enabled=lambda c: (c.plot.layout_mode()
1210
+ == figure_module.MODE_SIZE))
1211
+ r("view.follow_zoom", "Decorators follow the zoom (on / off)",
1212
+ lambda c: c.toggle_follow_zoom(), category="View",
1213
+ aliases=("decorators", "zoom", "labels move", "pin", "page",
1214
+ "structure", "arrow"))
1215
+ r("view.lock_framing", "Lock the current framing",
1216
+ lambda c: c.lock_framing(True), category="View",
1217
+ aliases=("freeze", "home", "reference", "F returns", "keep view",
1218
+ "limits"))
1219
+ r("view.unlock_framing", "Unlock the framing (F fits the data)",
1220
+ lambda c: c.lock_framing(False), category="View",
1221
+ aliases=("unfreeze", "auto", "fit"),
1222
+ enabled=lambda c: any(a.lock for a in c.doc.axes.values()))
1223
+ r("view.fit", "Fit the view", lambda c: c.plot.fit(),
1224
+ category="View", shortcut="F or Home", enabled=any_scan)
1225
+ # MestReNova's M: two numbers, typed straight through.
1226
+ r("view.x_range", "Set the x range...", lambda c: c.ask_x_range(),
1227
+ category="View", key="M", shortcut="M", enabled=any_scan,
1228
+ aliases=("limits", "xlim", "zoom to", "from to", "range"))
1229
+ r("figure.offset_markers", "Show or hide the y-offset markers",
1230
+ lambda c: c.toggle_offset_markers(), category="Object",
1231
+ enabled=any_scan,
1232
+ aliases=("offset", "yoffset", "add_yoffset_markers", "stack"))
1233
+ r("select.offset_markers", "Select every offset marker",
1234
+ lambda c: c.select_offset_markers(), category="Select",
1235
+ enabled=lambda c: c.doc.offset_markers and bool(c.doc.scans),
1236
+ aliases=("offset", "yoffset", "markers"))
1237
+ r("view.axis_temperature", "X axis: temperature",
1238
+ lambda c: c.set_axis(model.AXIS_TEMPERATURE), category="View",
1239
+ enabled=lambda c: c.doc.x_axis != model.AXIS_TEMPERATURE)
1240
+ r("view.axis_time", "X axis: time",
1241
+ lambda c: c.set_axis(model.AXIS_TIME), category="View",
1242
+ enabled=lambda c: c.doc.x_axis != model.AXIS_TIME)
1243
+ for unit in units.UNITS:
1244
+ r("view.unit_" + unit.replace("/", "_"),
1245
+ "Y axis: {}".format(unit),
1246
+ (lambda u: lambda c: c.set_unit(u))(unit), category="View",
1247
+ enabled=(lambda u: lambda c: c.doc.y_unit != u)(unit))
1248
+ # SDT and TGA runs: a segment's mass is a scan of its own, on the
1249
+ # mass axis.
1250
+ for unit, words in ((model.WEIGHT_PCT, "% of the sample mass"),
1251
+ (model.WEIGHT_MG, "mg")):
1252
+ r("view.weight_" + ("percent" if unit == model.WEIGHT_PCT
1253
+ else "mg"),
1254
+ "Mass axis: {}".format(words),
1255
+ (lambda u: lambda c: c.set_weight_unit(u))(unit),
1256
+ category="View", aliases=("tga", "sdt", "mass", "weight"),
1257
+ enabled=(lambda u: lambda c: (c.doc.weight_unit != u
1258
+ and c.has_weight()))(unit))
1259
+ r("view.weights", "Show or hide the mass curves",
1260
+ lambda c: c.toggle_signal(model.SIGNAL_MASS), category="View",
1261
+ aliases=("tga", "sdt", "mass loss", "weight", "thermogravimetry",
1262
+ "m%"),
1263
+ enabled=lambda c: c.has_weight())
1264
+ # The DTG: the mass's derivative, on the y axis the heat flow
1265
+ # otherwise has.
1266
+ r("view.dtg", "Show or hide the DTG curves",
1267
+ lambda c: c.toggle_signal(model.SIGNAL_DTG), category="View",
1268
+ aliases=("dtg", "derivative", "tga", "mass loss rate",
1269
+ "thermogravimetry"),
1270
+ enabled=lambda c: c.has_weight())
1271
+ for unit, name in ((dtg.PER_DEGREE, "per_degree"),
1272
+ (dtg.PER_MINUTE, "per_minute")):
1273
+ r("view.dtg_" + name, "DTG axis: {}".format(unit),
1274
+ (lambda u: lambda c: c.set_dtg_unit(u))(unit),
1275
+ category="View", aliases=("dtg", "derivative"),
1276
+ enabled=(lambda u: lambda c: (c.doc.dtg_unit != u
1277
+ and c.has_weight()))(unit))
1278
+ r("view.heat_flows", "Show or hide the heat flow curves",
1279
+ lambda c: c.toggle_signal(model.SIGNAL_HEAT), category="View",
1280
+ aliases=("sdt", "dsc", "heat flow"),
1281
+ enabled=lambda c: c.has_weight())
1282
+ # The m% at chosen temperatures, on every selected mass curve (or
1283
+ # every one shown) at once.
1284
+ r("analysis.mass_at", "Mass at temperatures...",
1285
+ lambda c: c.ask_mass_temperatures(), category="Analyse",
1286
+ aliases=("m%", "mass", "tga", "weight", "annotate", "residue"),
1287
+ enabled=lambda c: bool(c.mass_targets()))
1288
+ for theme in (plot_module.THEME_DARK, plot_module.THEME_LIGHT,
1289
+ plot_module.THEME_BOOMBOX):
1290
+ r("view.theme_" + theme.replace("-", "_"),
1291
+ "Theme: {}".format(theme),
1292
+ (lambda name: lambda c: c.set_theme(name))(theme),
1293
+ category="View", aliases=("dark", "light", "colours",
1294
+ "boombox", "owb", "green"),
1295
+ enabled=(lambda name: lambda c: c.doc.theme != name)(theme))
1296
+ r("view.outliner", "Show or hide the outliner",
1297
+ lambda c: c._outliner_dock.setVisible(
1298
+ not c._outliner_dock.isVisible()),
1299
+ category="View", key="N", shortcut="N")
1300
+
1301
+ r("measure.start", "Measure on the selected scan",
1302
+ lambda c: c.plot.start_measure(), category="Analyse", key="C",
1303
+ shortcut="C, or double-click-drag a curve",
1304
+ enabled=lambda c: len(c.doc.selected_scans()) == 1,
1305
+ aliases=("analyse", "onset", "integrate", "cursors", "tg"))
1306
+ r("measure.apply", "Analyse the interval...",
1307
+ lambda c: c.plot.measure_confirm(), category="Analyse",
1308
+ shortcut="Enter, with both cursors down",
1309
+ enabled=lambda c: bool(c.plot.measuring()
1310
+ and len(c.plot.measuring()["cursors"]) >= 2),
1311
+ aliases=("onset", "integrate", "glass transition", "enthalpy",
1312
+ "compute", "run"))
1313
+ r("measure.cancel", "Stop measuring",
1314
+ lambda c: c.plot.end_measure(), category="Analyse",
1315
+ enabled=lambda c: c.plot.measuring() is not None)
1316
+ for name, unit in (("Celsius", units.TEMP_C), ("Kelvin", units.TEMP_K),
1317
+ ("Fahrenheit", units.TEMP_F)):
1318
+ r("view.x_unit_" + unit,
1319
+ "X axis in {}".format(name),
1320
+ (lambda u: lambda c: c.set_x_unit(u))(unit), category="View",
1321
+ enabled=(lambda u: lambda c: (
1322
+ c.doc.x_unit != u
1323
+ and c.doc.x_axis == model.AXIS_TEMPERATURE))(unit))
1324
+ # No key: Ctrl+L belongs to the label alignment below. The
1325
+ # outliner's tick and F3 still toggle it.
1326
+ r("legend.toggle", "Show or hide the legend",
1327
+ lambda c: c.toggle_legend(), category="Object",
1328
+ aliases=("key", "which colour is which"))
1329
+ # The template's `flush` for analysis labels, on keys: which edge of
1330
+ # the text sits on its arrow.
1331
+ flushable = lambda c: bool(c.flush_targets()) or any(
1332
+ isinstance(o, model.TextLabel) for o in c.doc.selected())
1333
+ for side, key, words in (
1334
+ (style.FLUSH_LEFT, "Ctrl+L", "left"),
1335
+ (style.FLUSH_RIGHT, "Ctrl+R", "right"),
1336
+ (style.FLUSH_CENTER, "Ctrl+M", "centred")):
1337
+ r("analysis.flush_" + side,
1338
+ "Align labels {}".format(words),
1339
+ (lambda f: lambda c: c.set_flush(f))(side), category="Object",
1340
+ key=key, shortcut=key, enabled=flushable,
1341
+ aliases=("flush", "alignment", "justify", words))
1342
+ r("legend.settings", "Legend settings...",
1343
+ lambda c: c.edit_object(c.doc.legend), category="Object")
1344
+ r("label.add", "Add a label...", lambda c: c.add_label(),
1345
+ category="Object", key="Ctrl+T", shortcut="Ctrl+T",
1346
+ aliases=("text", "caption", "annotate", "title"))
1347
+ r("label.vline", "Add a marker line...", lambda c: c.add_marker_line(),
1348
+ category="Object",
1349
+ shortcut="or right-click the plot",
1350
+ aliases=("vertical line", "axvline", "mark", "melting point",
1351
+ "line", "marker line", "tm"))
1352
+ r("label.note", "Add a note with an arrow...",
1353
+ lambda c: c.add_note(), category="Object", key="Ctrl+Shift+T",
1354
+ shortcut="Ctrl+Shift+T, or right-click a curve",
1355
+ aliases=("note", "callout", "annotate", "arrow", "leader",
1356
+ "pointer", "comment"))
1357
+ # Blender's Ctrl+P and Alt+P (Help needs no key of its own).
1358
+ r("label.parent", "Give the selected labels to the selected scan",
1359
+ lambda c: c.parent_selected(), category="Object", key="Ctrl+P",
1360
+ shortcut="Ctrl+P, or drag the label onto the scan in the outliner",
1361
+ enabled=lambda c: (bool(c.selected_labels())
1362
+ and len(c.doc.selected_scans()) == 1),
1363
+ aliases=("parent", "attach", "belongs to", "owner", "link"))
1364
+ r("label.unparent", "Free the selected labels from their scan",
1365
+ lambda c: c.unparent_selected(), category="Object",
1366
+ key="Alt+P", shortcut="Alt+P",
1367
+ enabled=lambda c: any(lb.scan is not None
1368
+ for lb in c.selected_labels()),
1369
+ aliases=("unparent", "clear parent", "detach", "free"))
1370
+ for which in ("x", "y"):
1371
+ for part, words in (("spine", "axis: ticks and frame"),
1372
+ ("numbers", "axis numbers"),
1373
+ ("caption", "axis caption")):
1374
+ r("axis.{}_{}".format(which, part if part != "spine"
1375
+ else "settings"),
1376
+ "{} {}...".format(which.upper(), words),
1377
+ (lambda w, pt: lambda c: c.edit_object(
1378
+ c.doc.axes[w], part=pt))(which, part),
1379
+ category="View",
1380
+ aliases=("ticks", "spine", "minor", "major", "frame",
1381
+ "locator", "numbers", "caption", "label"))
1382
+ r("arrow.settings", "Heat-flow arrow settings...",
1383
+ lambda c: c.edit_object(c.doc.arrow), category="Arrow")
1384
+ r("arrow.flip", "Flip the heat-flow direction",
1385
+ lambda c: c.flip_arrow(), category="Arrow",
1386
+ aliases=("exo", "endo", "invert", "upside down"))
1387
+ r("arrow.relabel", "Say it the other way round (exo / endo)",
1388
+ lambda c: c.relabel_arrow(), category="Arrow")
1389
+
1390
+ r("app.operator_search", "Search operators...",
1391
+ lambda c: c.operator_search(), category="App", key="F3",
1392
+ shortcut="F3", aliases=("command palette", "find command", "menu"))
1393
+ r("app.log", "Open the log folder", lambda c: c.open_log_folder(),
1394
+ category="App", aliases=("errors", "debug", "crash", "log"))
1395
+ r("app.about", "About {}".format(branding.APP_NAME),
1396
+ lambda c: c.show_about(), category="App",
1397
+ aliases=("version", "help", "licence", "license", "credits"))
1398
+ r("figure.layout", "Figure size and margins...",
1399
+ lambda c: c.edit_figure(), category="Edit",
1400
+ aliases=("size", "aspect ratio", "width", "height", "margins",
1401
+ "centimetres", "inches", "page", "export size", "word"))
1402
+ r("style.preset_apply", "Apply a style preset...",
1403
+ lambda c: c.choose_preset(), category="Edit",
1404
+ aliases=("preset", "style", "template", "look", "thesis",
1405
+ "journal", "poster"))
1406
+ r("style.preset_save", "Save this figure's style as a preset...",
1407
+ lambda c: c.save_preset(), category="Edit",
1408
+ aliases=("preset", "style", "template", "look", "house style"))
1409
+ r("style.preset_folder", "Open the style presets folder",
1410
+ lambda c: c.open_presets_folder(), category="Edit",
1411
+ aliases=("preset", "share", "copy", "folder"))
1412
+ r("app.settings", "Settings...", lambda c: c.open_settings(),
1413
+ category="App", key="Ctrl+,", shortcut="Ctrl+,",
1414
+ aliases=("preferences", "defaults", "house style", "font size",
1415
+ "text size", "flush", "alignment", "options"))
1416
+
1417
+ def _install_shortcuts(self):
1418
+ """One QAction per keyed operator. A clash is a startup error.
1419
+
1420
+ Qt does not pick a winner when two actions share a shortcut: it
1421
+ reports an ambiguous overload and fires NEITHER, which looks like a
1422
+ key that stopped working for no reason.
1423
+ """
1424
+ clashes = self.ops.duplicate_keys()
1425
+ if clashes:
1426
+ raise RuntimeError("shortcut claimed twice: {}".format(clashes))
1427
+ for op in self.ops.keyed():
1428
+ for key in ops.chord_variants(op.key) + list(op.extra_keys):
1429
+ action = QAction(op.label, self)
1430
+ action.setShortcut(QKeySequence(key))
1431
+ action.setShortcutContext(Qt.WindowShortcut)
1432
+ action.triggered.connect(
1433
+ (lambda o: lambda _c=False: self.run_op(o.id))(op))
1434
+ self.addAction(action)
1435
+
1436
+ #: The menu bar, kept short: File, Edit, a Search button and Help. The
1437
+ #: Object, Transform, View and Arrow operators are one F3 away,
1438
+ #: filtered by the selection, which a menu cannot do.
1439
+ MENUS = (
1440
+ ("Fi&le", ("file.new", "file.open", "file.session_open", None,
1441
+ "file.session_save", "file.session_save_as", None,
1442
+ "file.export_image", "file.export_csv",
1443
+ "file.export_driver", None, "file.close")),
1444
+ ("&Edit", ("edit.undo", "edit.redo", "edit.copy", "edit.paste",
1445
+ "edit.paste_text", None,
1446
+ "select.all",
1447
+ "select.none", "select.invert", "select.same_sample",
1448
+ None, "figure.layout", "app.settings",
1449
+ ("Style presets", "presets"),
1450
+ ("Theme", ("view.theme_blender_default",
1451
+ "view.theme_light", "view.theme_boombox")))),
1452
+ ("Help", ("app.log", "app.about")),
1453
+ )
1454
+
1455
+ def _build_menus(self):
1456
+ bar = self.menuBar()
1457
+ self._menu_actions = []
1458
+ #: {title: QMenu}, kept so the menus can be reached again (a QMenu
1459
+ #: fetched back through a temporary QAction wrapper can already be
1460
+ #: gone on the Python side).
1461
+ self.menus = {}
1462
+ for title, ids in self.MENUS:
1463
+ if title.replace("&", "") == "Help":
1464
+ # SEARCH is a button on the bar itself rather than an entry
1465
+ # inside Help: it is how everything else is reached now.
1466
+ search = bar.addAction("&Search")
1467
+ search.setToolTip("Search every operator (F3)")
1468
+ search.triggered.connect(
1469
+ lambda _c=False: self.run_op("app.operator_search"))
1470
+ self._search_action = search
1471
+ menu = bar.addMenu(title)
1472
+ self.menus[title] = menu
1473
+ self.menus[title.replace("&", "")] = menu
1474
+ menu.aboutToShow.connect(self._sync_menu_state)
1475
+ self._fill_menu(menu, ids)
1476
+
1477
+ def _fill_menu(self, menu, ids):
1478
+ """Entries by operator id; None is a separator, and a pair
1479
+ `(title, ids)` a submenu - the Theme under Edit."""
1480
+ for op_id in ids:
1481
+ if op_id is None:
1482
+ menu.addSeparator()
1483
+ continue
1484
+ if isinstance(op_id, tuple) and op_id[1] == "presets":
1485
+ # Filled from the presets folder each time it opens.
1486
+ submenu = menu.addMenu(op_id[0])
1487
+ self.menus[op_id[0]] = submenu
1488
+ submenu.aboutToShow.connect(
1489
+ (lambda m: lambda: self._fill_presets_menu(m))(submenu))
1490
+ continue
1491
+ if isinstance(op_id, tuple):
1492
+ submenu = menu.addMenu(op_id[0])
1493
+ self.menus[op_id[0]] = submenu
1494
+ submenu.aboutToShow.connect(self._sync_menu_state)
1495
+ self._fill_menu(submenu, op_id[1])
1496
+ continue
1497
+ op = self.ops.get(op_id)
1498
+ if op is None:
1499
+ continue
1500
+ action = menu.addAction(op.label)
1501
+ if op.key:
1502
+ action.setShortcut(QKeySequence(op.key))
1503
+ # The window-level QAction already fires this; the menu
1504
+ # entry only SHOWS the key, or Qt reports an ambiguous
1505
+ # overload and neither of them works.
1506
+ action.setShortcutVisibleInContextMenu(True)
1507
+ action.setShortcutContext(Qt.WidgetShortcut)
1508
+ if op.id.startswith("view.theme_"):
1509
+ action.setCheckable(True)
1510
+ action.triggered.connect(
1511
+ (lambda o: lambda _c=False: self.run_op(o.id))(op))
1512
+ self._menu_actions.append((action, op))
1513
+
1514
+ def _sync_menu_state(self):
1515
+ for action, op in self._menu_actions:
1516
+ action.setEnabled(op.enabled(self))
1517
+ if action.isCheckable():
1518
+ # The theme in force is the ticked (and greyed) one.
1519
+ action.setChecked(not op.enabled(self))
1520
+
1521
+ def run_op(self, op_id):
1522
+ op = self.ops.get(op_id)
1523
+ if op is None or not op.enabled(self):
1524
+ return False
1525
+ op.run(self)
1526
+ return True
1527
+
1528
+ def popups(self):
1529
+ """This window's pop-ups that are open, most recently opened LAST.
1530
+
1531
+ Every visible dialog whose parent chain leads here: the settings of
1532
+ an object, an analysis opened from a scan's settings, the settings
1533
+ page. Found from the widgets themselves rather than from a list kept
1534
+ by hand, so a new kind of pop-up cannot be forgotten.
1535
+ """
1536
+ found = []
1537
+ for widget in QApplication.topLevelWidgets():
1538
+ if (widget is self or not widget.isVisible()
1539
+ or not isinstance(widget, QDialog)):
1540
+ continue
1541
+ parent = widget.parentWidget()
1542
+ while parent is not None and parent is not self:
1543
+ parent = parent.parentWidget()
1544
+ if parent is self:
1545
+ found.append(widget)
1546
+ tracked = [d for d in getattr(self, "_dialogs", []) if d in found]
1547
+ return [w for w in found if w not in tracked] + tracked
1548
+
1549
+ def close_step(self):
1550
+ """Ctrl+W, by what is in front: a pop-up if any is open, else the
1551
+ current tab (asking to save), else - the window showing its blank
1552
+ background - the program.
1553
+
1554
+ The active pop-up when one has the focus, otherwise the one opened
1555
+ last. Closing a pop-up is what its own X does. Returns what closed:
1556
+ the pop-up, "tab", or None when it was the window.
1557
+ """
1558
+ popups = self.popups()
1559
+ if not popups:
1560
+ if self._figures:
1561
+ return "tab" if self.close_tab() else None
1562
+ self.close()
1563
+ return None
1564
+ active = QApplication.activeWindow()
1565
+ target = active if active in popups else popups[-1]
1566
+ target.close()
1567
+ self.note.setText("Closed {}".format(target.windowTitle()))
1568
+ return target
1569
+
1570
+ def operator_search(self):
1571
+ dialog = OperatorPalette(self.ops, self, self, self._last_operator)
1572
+ if dialog.exec() and dialog.chosen:
1573
+ self._last_operator = ""
1574
+ self.run_op(dialog.chosen)
1575
+
1576
+ def open_settings(self):
1577
+ """The house style page. One at a time: a second copy editing the
1578
+ same defaults would only disagree with the first."""
1579
+ existing = getattr(self, "_settings_dialog", None)
1580
+ if existing is not None and existing.isVisible():
1581
+ existing.raise_()
1582
+ existing.activateWindow()
1583
+ return existing
1584
+ dialog = SettingsDialog(self)
1585
+ self._settings_dialog = dialog
1586
+ dialog.show()
1587
+ dialog.raise_()
1588
+ return dialog
1589
+
1590
+ # ------------------------------------------------------------- the menus
1591
+ def _context_menu(self, obj, pos):
1592
+ menu = self.context_menu_for(obj)
1593
+ if not menu.isEmpty():
1594
+ menu.exec(pos if isinstance(pos, QPoint) else QPoint(pos))
1595
+
1596
+ def context_menu_for(self, obj):
1597
+ """The right-click menu for `obj` (None: the empty plot), BUILT and
1598
+ not shown - a modal `exec` in a test hangs rather than fails."""
1599
+ menu = QMenu(self)
1600
+ if isinstance(obj, model.Scan):
1601
+ self._scan_menu(menu, obj)
1602
+ elif isinstance(obj, model.HeatFlowArrow):
1603
+ for op_id in ("arrow.settings", "arrow.flip", "arrow.relabel"):
1604
+ self._menu_op(menu, op_id)
1605
+ menu.addSeparator()
1606
+ hide = menu.addAction("Hide the arrow")
1607
+ hide.triggered.connect(
1608
+ lambda _c=False: self._set_visible(obj, False))
1609
+ elif isinstance(obj, model.OffsetMarker):
1610
+ many = len(self.settings_group(obj))
1611
+ act = menu.addAction(
1612
+ "Settings for the {} selected markers...".format(many + 1)
1613
+ if many else "Offset marker settings...")
1614
+ act.triggered.connect(lambda _c=False: self.edit_object(obj))
1615
+ hide = menu.addAction("Hide this marker")
1616
+ hide.triggered.connect(
1617
+ lambda _c=False: self._set_visible(obj, False))
1618
+ self._menu_op(menu, "select.offset_markers")
1619
+ elif isinstance(obj, model.Analysis):
1620
+ many = len(self.settings_group(obj))
1621
+ act = menu.addAction(
1622
+ "Settings for the {} selected analyses...".format(many + 1)
1623
+ if many else "Settings for {}...".format(obj.summary()))
1624
+ act.triggered.connect(lambda _c=False: self.edit_object(obj))
1625
+ hide = menu.addAction("Hide this analysis")
1626
+ hide.triggered.connect(
1627
+ lambda _c=False: self._set_visible(obj, False))
1628
+ scan = menu.addAction("Settings for {}...".format(
1629
+ obj.scan.display_name()))
1630
+ scan.triggered.connect(lambda _c=False: self.edit_object(obj.scan))
1631
+ elif isinstance(obj, model.TextLabel):
1632
+ self._label_menu(menu, obj)
1633
+ elif isinstance(obj, model.Axis):
1634
+ self._axis_menu(menu, obj)
1635
+ elif isinstance(obj, model.Sample):
1636
+ act = menu.addAction("Sample settings for {}...".format(obj.name))
1637
+ act.triggered.connect(lambda _c=False: self.edit_sample(obj))
1638
+ rename = menu.addAction("Rename\tF2")
1639
+ rename.triggered.connect(
1640
+ lambda _c=False: self.outliner.rename(obj))
1641
+ source = menu.addAction("Change the source file...")
1642
+ source.setToolTip("Another .tri in its place; its curves keep "
1643
+ "their places, colours and labels.")
1644
+ source.triggered.connect(
1645
+ lambda _c=False: self.change_source(obj))
1646
+ if obj.scans:
1647
+ gone = menu.addAction("Remove {} from the plot".format(
1648
+ obj.name))
1649
+ gone.triggered.connect(
1650
+ lambda _c=False: self.remove_sample(obj))
1651
+ # The file itself: a file with no curve on the figure could not
1652
+ # be taken off it at all.
1653
+ shut = menu.addAction("Close {}\tDel".format(obj.name))
1654
+ shut.triggered.connect(lambda _c=False: self.close_sample(obj))
1655
+ else:
1656
+ # No Open or Select-all here; the page's colour, white and the
1657
+ # theme's a click away.
1658
+ for op_id in ("view.fit", "view.lock_framing",
1659
+ "view.unlock_framing", "arrange.stack"):
1660
+ self._menu_op(menu, op_id)
1661
+ paper = menu.addMenu("Background")
1662
+ # Held by its parent's wrapper: a submenu fetched back through
1663
+ # a temporary one can already be deleted (CLAUDE.md).
1664
+ menu._paper = paper
1665
+ white = paper.addAction("White")
1666
+ white.triggered.connect(
1667
+ lambda _c=False: self.set_background("#ffffff"))
1668
+ themed = paper.addAction("The theme's")
1669
+ themed.triggered.connect(lambda _c=False: self.set_background(None))
1670
+ other = paper.addAction("Other colour...")
1671
+ other.triggered.connect(lambda _c=False: self.ask_background())
1672
+ if self.doc.x_axis == model.AXIS_TEMPERATURE:
1673
+ at = (QPointF(self.plot._cursor)
1674
+ if self.plot._cursor is not None else None)
1675
+ line = menu.addAction("Add a marker line here...")
1676
+ line.triggered.connect(
1677
+ lambda _c=False: self.add_marker_line(at=at))
1678
+ if obj is not None and hasattr(obj, "z") and self.layered_selected():
1679
+ menu.addSeparator()
1680
+ for op_id in ("order.front", "order.forward", "order.backward",
1681
+ "order.back"):
1682
+ self._menu_op(menu, op_id)
1683
+ return menu
1684
+
1685
+ def _scan_menu(self, menu, scan):
1686
+ """Four entries, and only these (a mass curve: a fifth, its mass at
1687
+ the temperature clicked).
1688
+
1689
+ Everything else is in the outliner, the menus or F3 already, and
1690
+ the menu that pops up on a curve should be small. The molar mass is
1691
+ there because it is the one thing worth reaching for without leaving
1692
+ the curve; a label and a note because they are made AT the curve.
1693
+ """
1694
+ act = menu.addAction("Settings for {}...".format(scan.display_name()))
1695
+ act.triggered.connect(lambda _c=False: self.edit_object(scan))
1696
+ molar = menu.addAction("Set the molar mass...")
1697
+ molar.triggered.connect(lambda _c=False: self.ask_molar_mass([scan]))
1698
+ caption = menu.addAction("Add a label...")
1699
+ caption.triggered.connect(lambda _c=False: self.add_label(scan=scan))
1700
+ # Where the right-click was: the pointer leaves the plot for the
1701
+ # menu, and the note must point at the curve, not at the menu.
1702
+ at = (QPointF(self.plot._cursor) if self.plot._cursor is not None
1703
+ else None)
1704
+ note = menu.addAction("Add a note with an arrow here...")
1705
+ note.triggered.connect(lambda _c=False: self.add_note(at=at))
1706
+ # A mass curve's one more; a marker line is a temperature's, not a
1707
+ # curve's: right-click the plot beside it.
1708
+ if scan.is_mass and at is not None:
1709
+ here = menu.addAction("Mass at this temperature")
1710
+ here.triggered.connect(
1711
+ lambda _c=False: self.mass_here(scan, at))
1712
+ # Exactly two selected, this one of them: swap their places.
1713
+ pair = self.swappable()
1714
+ if pair and scan in pair:
1715
+ self._menu_op(menu, "arrange.swap")
1716
+
1717
+ def _axis_menu(self, menu, axis):
1718
+ """An axis's three windows, and the number under the pointer to
1719
+ hide (its tick stays) or show again."""
1720
+ for part, words in (("spine", "Axis settings (line and ticks)..."),
1721
+ ("numbers", "Number settings..."),
1722
+ ("caption", "Caption settings...")):
1723
+ act = menu.addAction(words)
1724
+ act.triggered.connect(
1725
+ lambda _c=False, p=part: self.edit_object(axis, part=p))
1726
+ at = (QPointF(self.plot._cursor) if self.plot._cursor is not None
1727
+ else None)
1728
+ found = self.plot.number_at(at)
1729
+ hidden = self.hidden_numbers(axis)
1730
+ if (found is not None and found[0] is axis) or hidden:
1731
+ menu.addSeparator()
1732
+ if found is not None and found[0] is axis:
1733
+ _axis, value, text, is_hidden = found
1734
+ act = menu.addAction("{} the number {}".format(
1735
+ "Show" if is_hidden else "Hide", text))
1736
+ act.triggered.connect(
1737
+ lambda _c=False, v=value, h=not is_hidden:
1738
+ self.hide_number(axis, v, h))
1739
+ if hidden:
1740
+ back = menu.addAction("Show every number ({} hidden)".format(
1741
+ len(hidden)))
1742
+ back.triggered.connect(
1743
+ lambda _c=False: self.set_hidden_numbers(axis, []))
1744
+
1745
+ def hidden_numbers(self, axis):
1746
+ """`axis`'s hidden numbers that apply in its current unit."""
1747
+ context = self.plot.axis_context(axis.which)
1748
+ if list(axis.hidden_context or []) != context:
1749
+ return []
1750
+ return list(axis.hidden_numbers or [])
1751
+
1752
+ def hide_number(self, axis, value, hide=True):
1753
+ """One number of `axis` hidden (its tick stays) or shown again.
1754
+ One undo step."""
1755
+ near = abs(self.plot.number_step(axis)) * 1e-6
1756
+ kept = [h for h in self.hidden_numbers(axis)
1757
+ if abs(float(h) - value) > near]
1758
+ if hide:
1759
+ kept.append(float(value))
1760
+ return self.set_hidden_numbers(axis, kept)
1761
+
1762
+ def set_hidden_numbers(self, axis, values):
1763
+ """`axis`'s hidden numbers, as values in its current unit; one undo
1764
+ step. A number shown again on an EXACT figure gets its margin back
1765
+ when it reaches past the one there is (`_hold_margins`)."""
1766
+ values = sorted(float(v) for v in values)
1767
+ context = (self.plot.axis_context(axis.which) if values else None)
1768
+ changes = [(axis, name, new) for name, new in (
1769
+ ("hidden_numbers", values), ("hidden_context", context))
1770
+ if getattr(axis, name) != new]
1771
+ if not changes:
1772
+ return False
1773
+ fewer = len(values) < len(self.hidden_numbers(axis))
1774
+ opened = getattr(self.undo, "_group", None) is None
1775
+ if opened:
1776
+ self.undo.begin_group("axis numbers")
1777
+ try:
1778
+ self.undo.set_props(changes, "axis numbers")
1779
+ if fewer:
1780
+ self._hold_margins()
1781
+ finally:
1782
+ if opened:
1783
+ self.undo.end_group()
1784
+ self.refresh()
1785
+ return True
1786
+
1787
+ def _hold_margins(self):
1788
+ """Every margin of an exact figure at least what it holds - raised,
1789
+ never lowered - so something that grew is not cut off."""
1790
+ plot = self.plot
1791
+ if plot.layout_mode() != figure_module.MODE_SIZE:
1792
+ return []
1793
+ layout = self.doc.figure
1794
+ grow = dict((side, least) for side, least in zip(
1795
+ figure_module.MARGIN_SIDES, plot.least_page_margins())
1796
+ if getattr(layout, "margin_" + side) < least - 1e-9)
1797
+ return self.set_page_margins(grow, "room for the numbers") \
1798
+ if grow else []
1799
+
1800
+ def _menu_op(self, menu, op_id):
1801
+ op = self.ops.get(op_id)
1802
+ if op is None:
1803
+ return
1804
+ action = menu.addAction(op.label
1805
+ + ("\t" + op.key if op.key else ""))
1806
+ action.setEnabled(op.enabled(self))
1807
+ action.triggered.connect(lambda _c=False: self.run_op(op_id))
1808
+
1809
+ # ------------------------------------------------------------- documents
1810
+ def open_log_folder(self):
1811
+ """Help > Open the log folder: where the log and any crash file
1812
+ are (`core/log.py`)."""
1813
+ from PySide6.QtCore import QUrl
1814
+ from PySide6.QtGui import QDesktopServices
1815
+ from ..core import log
1816
+ folder = os.path.dirname(log.path())
1817
+ if not os.path.isdir(folder):
1818
+ os.makedirs(folder)
1819
+ QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
1820
+ self.note.setText(folder)
1821
+ return folder
1822
+
1823
+ def show_error(self, text):
1824
+ """An error was logged: say so where it is seen."""
1825
+ self.note.setText("Error: {} - details in the log (Help > Open the "
1826
+ "log folder)".format(text))
1827
+ self.plot.flash("Something went wrong - see the log")
1828
+
1829
+ def new_figure_op(self):
1830
+ self.new_figure()
1831
+ self.note.setText("A new figure")
1832
+
1833
+ def open_files(self, paths=None):
1834
+ if paths is None:
1835
+ paths, _f = QFileDialog.getOpenFileNames(
1836
+ self, "Open TRIOS measurements", "",
1837
+ "TRIOS files (*.tri *.txt);;All files (*)")
1838
+ if paths:
1839
+ self.ensure_figure()
1840
+ queued = self.loader.load(paths or [])
1841
+ if queued:
1842
+ self.note.setText("Reading {} file(s)...".format(queued))
1843
+ return queued
1844
+
1845
+ def _loading_progress(self, done, total):
1846
+ if total and done < total:
1847
+ self.note.setText("Reading {} of {}...".format(done + 1, total))
1848
+
1849
+ def _sample_loaded(self, sample):
1850
+ self.ensure_figure()
1851
+ existing = self.doc.sample_for(sample.path)
1852
+ if existing is not None:
1853
+ self.doc.samples.remove(existing)
1854
+ for scan in list(existing.scans):
1855
+ self.doc.remove_scan(scan)
1856
+ self.doc.add_sample(sample, model.default_segments(sample))
1857
+ LOGGER.info("read %s", sample.path)
1858
+ self.undo.clear()
1859
+ self.note.setText(loader.summary(sample))
1860
+ self.statusBar().setToolTip(sample.note or "")
1861
+ self.refresh(keep_view=False)
1862
+
1863
+ def _sample_failed(self, path, message):
1864
+ self.note.setText("Could not read {}: {}".format(
1865
+ os.path.basename(path), message))
1866
+
1867
+ def toggle_segment(self, sample, seg, on=True,
1868
+ signal=model.SIGNAL_HEAT):
1869
+ """Put one curve of an open file on the plot, or take it off: a
1870
+ segment's heat flow, or (an SDT run) its mass.
1871
+
1872
+ Undoable, like everything else: the scan object is kept by the
1873
+ command, so an undo puts back the same one with its colour and its
1874
+ offset rather than a fresh copy.
1875
+ """
1876
+ with self._room_for_axes("show" if on else "hide"):
1877
+ self._toggle_segment(sample, seg, on, signal)
1878
+
1879
+ def _toggle_segment(self, sample, seg, on, signal):
1880
+ existing = [s for s in sample.scans
1881
+ if s.seg == seg and s.signal == signal]
1882
+ if on and existing and not all(s.visible for s in existing):
1883
+ self.undo.set_props([(s, "visible", True) for s in existing
1884
+ if not s.visible], "show")
1885
+ elif on and not existing:
1886
+ colour = model.PALETTE[len(self.doc.scans) % len(model.PALETTE)]
1887
+ scan = model.Scan(self.doc._next_id(), sample, seg, colour,
1888
+ signal)
1889
+ self.undo.push(undo.CallCommand(
1890
+ lambda: self.doc.insert_scan(scan),
1891
+ lambda: self.doc.detach_scan(scan),
1892
+ "show {}".format(scan.short_program())))
1893
+ elif not on and existing:
1894
+ self.remove_scans(existing)
1895
+ self.refresh()
1896
+
1897
+ def remove_scans(self, scans, label=None):
1898
+ """Take scans off the document, undoably.
1899
+
1900
+ The scans themselves are held by the command, so Ctrl+Z brings the
1901
+ SAME objects back, in their old places, with their offsets, colours
1902
+ and molar masses. With removal outside the stack, removing a file
1903
+ and pressing Ctrl+Z did nothing.
1904
+ """
1905
+ scans = [s for s in scans if s in self.doc.scans]
1906
+ if not scans:
1907
+ return
1908
+ places = [(scan, self.doc.scans.index(scan)) for scan in scans]
1909
+ label = label or "remove {} scan(s)".format(len(scans))
1910
+
1911
+ owned = {}
1912
+
1913
+ def remove():
1914
+ for scan, _index in places:
1915
+ owned[id(scan)] = self.doc.labels_of_removed(scan)
1916
+ self.doc.detach_scan(scan)
1917
+
1918
+ def restore():
1919
+ for scan, index in sorted(places, key=lambda pair: pair[1]):
1920
+ self.doc.insert_scan(scan, index)
1921
+ for label in owned.get(id(scan), ()):
1922
+ if label not in self.doc.labels:
1923
+ self.doc.labels.append(label)
1924
+
1925
+ with self._room_for_axes(label):
1926
+ self.undo.push(undo.CallCommand(remove, restore, label))
1927
+ self.refresh()
1928
+
1929
+ def remove_sample(self, sample):
1930
+ """Take a whole file off the plot, undoably."""
1931
+ self.remove_scans(list(sample.scans),
1932
+ "remove {}".format(sample.name))
1933
+
1934
+ def files_to_close(self):
1935
+ """The files whose rows are selected in the outliner while it has
1936
+ the keyboard: what Delete closes."""
1937
+ outliner = getattr(self, "outliner", None)
1938
+ if outliner is None:
1939
+ return []
1940
+ focus = QApplication.focusWidget()
1941
+ mine = focus is not None and (focus is outliner
1942
+ or outliner.isAncestorOf(focus))
1943
+ return outliner.selected_samples() if mine else []
1944
+
1945
+ def change_source(self, sample, path=None):
1946
+ """PowerPoint's "Change picture" for a file: another measurement
1947
+ in its place. The SAME sample
1948
+ object takes the new file's data, so its curves keep their places,
1949
+ offsets, colours, truncation and labels, and a figure copied from
1950
+ another is refilled with other samples. Its name, molar mass and
1951
+ exotherm direction are the new file's (a new sample; nothing is
1952
+ carried over that could be wrong for it). The file's own analyses
1953
+ are the new file's; the ones made here are measured again on it. A
1954
+ segment the new file does not have takes its curve off. One undo
1955
+ step. Returns the problems, or None when nothing was done."""
1956
+ if path is None:
1957
+ path, _f = QFileDialog.getOpenFileName(
1958
+ self, "Change the source of {}".format(sample.name),
1959
+ os.path.dirname(sample.path),
1960
+ "TRIOS files (*.tri *.txt);;All files (*)")
1961
+ if not path:
1962
+ return None
1963
+ if os.path.normcase(os.path.abspath(path)) == os.path.normcase(
1964
+ os.path.abspath(sample.path)):
1965
+ return None
1966
+ try:
1967
+ fresh = loader.read_sample(path)
1968
+ except Exception as exc:
1969
+ self.note.setText("Cannot read {}: {}".format(
1970
+ os.path.basename(path), exc))
1971
+ LOGGER.warning("change source: cannot read %s: %s", path, exc)
1972
+ return None
1973
+ doc = self.doc
1974
+ mine = list(sample.scans)
1975
+ gone = [s for s in mine if s.seg >= fresh.segment_count()]
1976
+ kept = [s for s in mine if s not in gone]
1977
+ old_state = dict(sample.__dict__)
1978
+ old_state["scans"] = list(mine)
1979
+ new_state = dict(fresh.__dict__)
1980
+ places = [(s, doc.scans.index(s)) for s in gone if s in doc.scans]
1981
+ analyses = dict((id(s), s._analyses) for s in mine)
1982
+ made_here = dict((id(s), [session._analysis_state(a)
1983
+ for a in (s._analyses or [])
1984
+ if a.source == "panel"]) for s in kept)
1985
+ owned = {}
1986
+ problems = []
1987
+
1988
+ def swap_in():
1989
+ for scan, _index in places:
1990
+ owned[id(scan)] = doc.labels_of_removed(scan)
1991
+ doc.detach_scan(scan)
1992
+ sample.__dict__.clear()
1993
+ sample.__dict__.update(new_state)
1994
+ sample.scans = list(kept)
1995
+ del problems[:]
1996
+ for scan in kept:
1997
+ scan._cache_key = None
1998
+ scan._analyses = None
1999
+ for saved in made_here[id(scan)]:
2000
+ cursors = saved.get("cursors") or []
2001
+ made = None
2002
+ if len(cursors) == 2:
2003
+ made = measure.run(saved.get("model", ""), scan,
2004
+ float(cursors[0]),
2005
+ float(cursors[1]),
2006
+ span=saved.get("span"))
2007
+ if made is None:
2008
+ problems.append("{}: {} could not be measured "
2009
+ "again".format(scan.display_name(),
2010
+ saved.get("model")))
2011
+ continue
2012
+ session._restore_analysis(made, saved, session.VERSION)
2013
+ for scan in gone:
2014
+ problems.append("{} has no segment {}: its curve is off "
2015
+ "the figure".format(sample.name,
2016
+ scan.seg + 1))
2017
+
2018
+ def swap_back():
2019
+ sample.__dict__.clear()
2020
+ sample.__dict__.update(old_state)
2021
+ sample.scans = [s for s in old_state["scans"] if s in kept]
2022
+ for scan in mine:
2023
+ scan._cache_key = None
2024
+ scan._analyses = analyses[id(scan)]
2025
+ for scan, index in sorted(places, key=lambda pair: pair[1]):
2026
+ doc.insert_scan(scan, index)
2027
+ for label in owned.get(id(scan), ()):
2028
+ if label not in doc.labels:
2029
+ doc.labels.append(label)
2030
+
2031
+ # CallCommand swaps it in (it applies itself when built).
2032
+ self.undo.push(undo.CallCommand(
2033
+ swap_in, swap_back, "change the source of a file"))
2034
+ self.refresh()
2035
+ self.note.setText("; ".join(problems) if problems else
2036
+ "Now from {}".format(os.path.basename(path)))
2037
+ return list(problems)
2038
+
2039
+ def close_sample(self, sample):
2040
+ """Close a file: its curves, their labels and the file itself, ONE
2041
+ undo step that brings back the same objects."""
2042
+ self.close_samples([sample])
2043
+
2044
+ def close_samples(self, samples):
2045
+ """Close files (`close_sample`), all of them ONE undo step."""
2046
+ doc = self.doc
2047
+ samples = [s for s in samples if s in doc.samples]
2048
+ if not samples:
2049
+ return
2050
+ order = sorted(samples, key=doc.samples.index)
2051
+ at = [(sample, doc.samples.index(sample)) for sample in order]
2052
+ places = [(scan, doc.scans.index(scan)) for sample in order
2053
+ for scan in sample.scans if scan in doc.scans]
2054
+ owned = {}
2055
+
2056
+ def close():
2057
+ for scan, _index in places:
2058
+ owned[id(scan)] = doc.labels_of_removed(scan)
2059
+ for sample in order:
2060
+ doc.close_sample(sample)
2061
+
2062
+ def reopen():
2063
+ for sample, index in at:
2064
+ if sample not in doc.samples:
2065
+ doc.samples.insert(min(index, len(doc.samples)), sample)
2066
+ for scan, index in sorted(places, key=lambda pair: pair[1]):
2067
+ doc.insert_scan(scan, index)
2068
+ for label in owned.get(id(scan), ()):
2069
+ if label not in doc.labels:
2070
+ doc.labels.append(label)
2071
+
2072
+ label = ("close {}".format(order[0].name) if len(order) == 1
2073
+ else "close {} files".format(len(order)))
2074
+ # CallCommand closes them (it applies itself when built).
2075
+ with self._room_for_axes(label):
2076
+ self.undo.push(undo.CallCommand(close, reopen, label))
2077
+ self.refresh()
2078
+
2079
+ def open_session(self, path=None):
2080
+ if path is None:
2081
+ path, _f = QFileDialog.getOpenFileName(
2082
+ self, "Open a session", "",
2083
+ "{} sessions (*{});;All files (*)".format(
2084
+ branding.APP_NAME, branding.SESSION_EXT))
2085
+ if not path:
2086
+ return None
2087
+ try:
2088
+ doc, problems = session.load(path, loader.read_sample)
2089
+ except Exception as exc:
2090
+ self.note.setText("Could not open the session: {}".format(exc))
2091
+ return None
2092
+ # A tab of its own, unless the current one is an untouched new
2093
+ # figure, which it then replaces.
2094
+ current = self._figure
2095
+ if (current is self._blank or current.doc.samples
2096
+ or current.doc.path or self.is_modified()):
2097
+ self.new_figure()
2098
+ self.doc = doc
2099
+ self.undo.clear()
2100
+ self.plot.set_document(doc)
2101
+ self.outliner.set_document(doc)
2102
+ self.note.setText("; ".join(problems) if problems
2103
+ else "Opened {}".format(os.path.basename(path)))
2104
+ LOGGER.info("opened session %s%s", path,
2105
+ " ({})".format("; ".join(problems)) if problems else "")
2106
+ self.refresh(keep_view=False)
2107
+ if doc.view:
2108
+ # The framing it was saved with: without it, a y range narrowed
2109
+ # to show a peak's label came back fitted.
2110
+ self.plot.restore_view(doc.view)
2111
+ # An older session's decorators keep their places in the view it
2112
+ # was saved with, and its owned labels hang from their curves where
2113
+ # they are drawn - before it counts as unchanged.
2114
+ if getattr(doc, "loaded_version", session.VERSION) == 7:
2115
+ # Version 7 placed them in the home frame; on the page now.
2116
+ doc.follow_zoom = True
2117
+ for artist, name, value in self.plot.reframed(False):
2118
+ setattr(artist, name, value)
2119
+ doc.follow_zoom = False
2120
+ self.plot.attach_all()
2121
+ self.mark_clean()
2122
+ return path
2123
+
2124
+ def save_session(self, ask=False, path=None):
2125
+ path = path or (self.doc.path if not ask else "")
2126
+ if not path:
2127
+ path, _f = QFileDialog.getSaveFileName(
2128
+ self, "Save the session",
2129
+ "figure" + branding.SESSION_EXT,
2130
+ "{} sessions (*{})".format(branding.APP_NAME,
2131
+ branding.SESSION_EXT))
2132
+ if not path:
2133
+ return None
2134
+ self._sync_view()
2135
+ try:
2136
+ session.save(self.doc, path)
2137
+ except OSError as exc:
2138
+ # A slot: an exception here is an abort, not a message.
2139
+ self.note.setText("Could not save: {}".format(exc))
2140
+ self.plot.flash("NOT saved - {}".format(exc.strerror or exc))
2141
+ return None
2142
+ self.mark_clean()
2143
+ self.note.setText("Saved {}".format(os.path.basename(path)))
2144
+ # A fading confirmation: the moment of saving is the moment nobody
2145
+ # is looking at the status bar.
2146
+ self.plot.flash("Saved {}".format(os.path.basename(path)))
2147
+ return path
2148
+
2149
+ # --------------------------------------------------------------- exports
2150
+ def ask_export(self):
2151
+ """The export dialog: `(path, light)`, or None if cancelled. A
2152
+ method of its own so a test can answer it without a modal loop."""
2153
+ name = (os.path.splitext(self.doc.path)[0] + ".svg"
2154
+ if self.doc.path else "dsc.svg")
2155
+ layout = self.doc.figure
2156
+ size = ("{:g} x {:g} {}, PNG at {:d} dpi".format(
2157
+ layout.width, layout.height, layout.unit, int(layout.dpi))
2158
+ if self.plot.layout_mode() == figure_module.MODE_SIZE
2159
+ else "as the window")
2160
+ dialog = ExportDialog(self, name, self._export_light, size)
2161
+ if not dialog.exec():
2162
+ return None
2163
+ return dialog.values()
2164
+
2165
+ def export_image(self, path=None, light=None):
2166
+ """The figure as SVG or PNG. `light` True is dark ink on white,
2167
+ for a page; False draws it in the theme on screen."""
2168
+ if path is None:
2169
+ chosen = self.ask_export()
2170
+ if not chosen:
2171
+ return None
2172
+ path, light = chosen
2173
+ self._export_light = bool(light)
2174
+ if light is None:
2175
+ light = self._export_light
2176
+ if not path:
2177
+ return None
2178
+ # The exo direction is NOT stamped on an image: whether an assumed
2179
+ # direction is right is the user's call.
2180
+ # It is still printed, and still written into the driver.
2181
+ warnings = export.warnings_for(self.doc, exo=False)
2182
+ if os.path.splitext(path)[1].lower() == ".svg":
2183
+ self._render_svg(path, warnings, light)
2184
+ else:
2185
+ self._render_png(path, warnings, light)
2186
+ # Printed as well as stamped - all of it, the exo direction and the
2187
+ # label notes included: the blinking label in the window can be
2188
+ # looked past, so the export says it out loud.
2189
+ for line in (export.warnings_for(self.doc)
2190
+ + export.label_notes(self.doc)):
2191
+ print("[{}] {}".format(branding.APP_NAME, line))
2192
+ self.note.setText(
2193
+ "Exported {}{}".format(os.path.basename(path),
2194
+ " - " + "; ".join(warnings)
2195
+ if warnings else ""))
2196
+ self.plot.flash("Exported {}".format(os.path.basename(path)))
2197
+ return path
2198
+
2199
+ def export_geometry(self):
2200
+ """`(canvas_w, canvas_h, dpi, width_px, height_px)` of an export.
2201
+
2202
+ A figure of exact size is exported at EXACTLY its size: width x
2203
+ height inches times its dpi, the axes box where its margins put it,
2204
+ whatever the window looks like. Otherwise the figure on screen, at
2205
+ twice the drawing resolution.
2206
+ """
2207
+ canvas_w, canvas_h = self.plot.canvas_size()
2208
+ if self.plot.layout_mode() == figure_module.MODE_SIZE:
2209
+ dpi = float(self.doc.figure.dpi)
2210
+ width_in, height_in = self.doc.figure.inches()
2211
+ return (canvas_w, canvas_h, dpi, int(round(width_in * dpi)),
2212
+ int(round(height_in * dpi)))
2213
+ dpi = 2.0 * figure_module.DESIGN_DPI
2214
+ return (canvas_w, canvas_h, dpi,
2215
+ int(round(canvas_w * dpi / figure_module.DESIGN_DPI)),
2216
+ int(round(canvas_h * dpi / figure_module.DESIGN_DPI)))
2217
+
2218
+ def _render_png(self, path, warnings, light=True):
2219
+ canvas_w, canvas_h, dpi, width_px, height_px = self.export_geometry()
2220
+ image = QImage(width_px, height_px, QImage.Format_ARGB32)
2221
+ painter = QPainter(image)
2222
+ painter.setRenderHint(QPainter.Antialiasing, True)
2223
+ scale = dpi / figure_module.DESIGN_DPI
2224
+ painter.scale(scale, scale)
2225
+ try:
2226
+ with paper_palette(self.plot, light), self.unselected():
2227
+ image.fill(QColor(self.doc.background) if self.doc.background
2228
+ else QColor(plot_module._BG) if not light
2229
+ else QColor(255, 255, 255))
2230
+ self.plot.paint_into(painter, columns=width_px)
2231
+ self._stamp(painter, warnings)
2232
+ finally:
2233
+ painter.end()
2234
+ # The DPI goes into the file AFTER painting: Qt turns a font's point
2235
+ # size into pixels with the device's DPI, and while painting that
2236
+ # has to be the drawing's own 96. Word reads it back and places the
2237
+ # picture at its physical size.
2238
+ per_metre = int(round(dpi / 0.0254))
2239
+ image.setDotsPerMeterX(per_metre)
2240
+ image.setDotsPerMeterY(per_metre)
2241
+ image.save(path)
2242
+
2243
+ def _render_svg(self, path, warnings, light=True):
2244
+ from PySide6.QtCore import QSize
2245
+ from PySide6.QtSvg import QSvgGenerator
2246
+ canvas_w, canvas_h = self.plot.canvas_size()
2247
+ generator = QSvgGenerator()
2248
+ generator.setFileName(path)
2249
+ # 96, the drawing's own resolution. The generator's default is 72,
2250
+ # which turned every point size into pixels at 72 and drew all the
2251
+ # text at three quarters of its size.
2252
+ generator.setResolution(int(figure_module.DESIGN_DPI))
2253
+ generator.setSize(QSize(int(round(canvas_w)), int(round(canvas_h))))
2254
+ generator.setViewBox(QRectF(0.0, 0.0, canvas_w, canvas_h))
2255
+ generator.setTitle("{} figure".format(branding.APP_NAME))
2256
+ generator.setDescription("; ".join(
2257
+ s.display_name() for s in self.doc.visible_scans()))
2258
+ painter = QPainter(generator)
2259
+ painter.setRenderHint(QPainter.Antialiasing, True)
2260
+ # The SVG writer ignores clipping; the plot marks where its clip
2261
+ # starts and ends, and `clip_svg` writes a real one in afterwards.
2262
+ self.plot._svg_clip = []
2263
+ try:
2264
+ with paper_palette(self.plot, light), self.unselected():
2265
+ painter.fillRect(QRectF(0.0, 0.0, canvas_w, canvas_h),
2266
+ QColor(self.doc.background)
2267
+ if self.doc.background
2268
+ else QColor(255, 255, 255) if light
2269
+ else QColor(plot_module._BG))
2270
+ self.plot.paint_into(painter, columns=int(canvas_w * 4))
2271
+ self._stamp(painter, warnings)
2272
+ finally:
2273
+ painter.end()
2274
+ clips, self.plot._svg_clip = self.plot._svg_clip, None
2275
+ plot_module.clip_svg(path, clips)
2276
+ if self.plot.layout_mode() == figure_module.MODE_SIZE:
2277
+ width_in, height_in = self.doc.figure.inches()
2278
+ _exact_svg_size(path, width_in * 25.4, height_in * 25.4)
2279
+
2280
+ @contextlib.contextmanager
2281
+ def unselected(self):
2282
+ """Nothing selected while this lasts: an export draws the figure,
2283
+ not the hand working on it - no orange."""
2284
+ doc = self.doc
2285
+ chosen = [o for o in doc.objects() if o.selected]
2286
+ chosen += [s.marker for s in doc.scans
2287
+ if s.marker.selected and s.marker not in chosen]
2288
+ for obj in chosen:
2289
+ obj.selected = False
2290
+ try:
2291
+ yield
2292
+ finally:
2293
+ for obj in chosen:
2294
+ obj.selected = True
2295
+
2296
+ def _stamp(self, painter, warnings):
2297
+ """Draw the warnings onto the figure itself.
2298
+
2299
+ Nobody must be able to render an image with misleading data by
2300
+ ignoring the blinking in the window. A line of red text on the
2301
+ figure travels with it, which a console message does not.
2302
+ """
2303
+ if not warnings:
2304
+ return
2305
+ painter.save()
2306
+ font = painter.font()
2307
+ font.setBold(True)
2308
+ painter.setFont(font)
2309
+ painter.setPen(QColor(200, 30, 30))
2310
+ y = 18
2311
+ for line in warnings:
2312
+ painter.drawText(14, y, line)
2313
+ y += 15
2314
+ painter.restore()
2315
+
2316
+ def export_csv(self, path=None):
2317
+ if path is None:
2318
+ path, _f = QFileDialog.getSaveFileName(
2319
+ self, "Export the curves", "dsc.csv",
2320
+ "Comma-separated values (*.csv)")
2321
+ if not path:
2322
+ return None
2323
+ written = export.curves_csv(self.doc, path)
2324
+ self.note.setText("Exported {}".format(os.path.basename(path))
2325
+ if written else "Nothing to export")
2326
+ if written:
2327
+ self.plot.flash("Exported {}".format(os.path.basename(path)))
2328
+ return written
2329
+
2330
+ def export_driver(self, path=None):
2331
+ if path is None:
2332
+ path, _f = QFileDialog.getSaveFileName(
2333
+ self, "Export as a DSC_Plotter driver", "DSC_Plotter.py",
2334
+ "Python (*.py)")
2335
+ if not path:
2336
+ return None
2337
+ self.doc.view_x_hint = self.plot.view_x()
2338
+ self.doc.view_y_hint = self.plot.view_y()
2339
+ self.doc.label_hints = self.plot.label_hints()
2340
+ self.doc.view_y2_hint = (self.plot.view_y2()
2341
+ if self.plot.y2_shown() else None)
2342
+ self.doc.marker_hint = self.marker_hint()
2343
+ written, kind = export.write_driver(self.doc, path)
2344
+ self.note.setText(
2345
+ "Wrote {} ({})".format(os.path.basename(written), kind))
2346
+ self.plot.flash("Wrote {}".format(os.path.basename(written)))
2347
+ return written
2348
+
2349
+ def marker_hint(self):
2350
+ """`{id(marker): (celsius, yoff_label)}` as the plot draws each
2351
+ marker, for the driver: where an unplaced one lands depends on the
2352
+ view, and the template takes the label's distance as a fraction of
2353
+ the axes height."""
2354
+ rect = self.plot.plot_rect()
2355
+ hint = {}
2356
+ for scan in self.doc.scans:
2357
+ marker = scan.marker
2358
+ hint[id(marker)] = (self.plot.marker_celsius(marker),
2359
+ -self.plot.marker_dy(marker, rect)
2360
+ / max(1.0, rect.height()))
2361
+ return hint
2362
+
2363
+ # ------------------------------------------------------------- selection
2364
+ def select_all(self, on=True):
2365
+ self.doc.select_all(on)
2366
+ self._selection_changed()
2367
+ self.plot.update()
2368
+
2369
+ def invert_selection(self):
2370
+ for obj in self.doc.objects():
2371
+ obj.selected = not obj.selected
2372
+ self._selection_changed()
2373
+ self.plot.update()
2374
+
2375
+ def select_same_sample(self):
2376
+ samples = {id(s.sample) for s in self.doc.selected_scans()}
2377
+ for scan in self.doc.scans:
2378
+ if id(scan.sample) in samples:
2379
+ scan.selected = True
2380
+ self._selection_changed()
2381
+ self.plot.update()
2382
+
2383
+ # ------------------------------------------------------------- arranging
2384
+ def _curve_of(self, scan):
2385
+ # The KEPT part: a truncated start-up hook must not steer a stack or
2386
+ # an alignment any more than it steers the fit.
2387
+ return scan.kept_curve(self.doc.x_axis, self.doc.unit_for(scan),
2388
+ self.doc.exo, self.doc.x_unit)
2389
+
2390
+ def spreadable(self):
2391
+ """S spreads the selection when it is scans and nothing else, two
2392
+ or more of them drawn (`PlotWidget.start_spread`)."""
2393
+ chosen = self.doc.selected()
2394
+ return (len([s for s in chosen if isinstance(s, model.Scan)
2395
+ and s.visible]) >= 2
2396
+ and all(isinstance(o, model.Scan) for o in chosen))
2397
+
2398
+ @staticmethod
2399
+ def _by_signal(scans):
2400
+ """`scans` in groups of one signal each (heat flows, masses), in
2401
+ order: an arrangement is worked out in ONE axis's unit."""
2402
+ groups = {}
2403
+ for scan in scans:
2404
+ groups.setdefault(scan.signal, []).append(scan)
2405
+ return list(groups.values())
2406
+
2407
+ def stack_selected(self):
2408
+ """"Stack evenly": in the order the offsets already have, ties in
2409
+ the outliner's (its top at the top) - S's order - from the lowest
2410
+ offset up."""
2411
+ scans = self.doc.selected_scans() or self.doc.visible_scans()
2412
+ changes = []
2413
+ for group in self._by_signal(scans):
2414
+ step = arrange.suggested_step(group, self._curve_of)
2415
+ if not step:
2416
+ continue
2417
+ first = min((s.offset for s in group), default=0.0)
2418
+ upward = self.plot.stack_order(group)[::-1]
2419
+ changes.extend(arrange.stack(upward, step, first))
2420
+ if changes:
2421
+ self.undo.set_props(changes, "stack")
2422
+
2423
+ def distribute_selected(self):
2424
+ changes = []
2425
+ for group in self._by_signal(self.doc.selected_scans()):
2426
+ changes.extend(arrange.distribute(group))
2427
+ self.undo.set_props(changes, "distribute")
2428
+
2429
+ def align_selected(self):
2430
+ scans = self.doc.selected_scans()
2431
+ # The ACTIVE scan is the first selected one, which is the one the
2432
+ # eye starts on and the one the outliner lists first; the others of
2433
+ # its axis align to it.
2434
+ if len(scans) < 2:
2435
+ return
2436
+ same = [s for s in scans[1:] if s.signal == scans[0].signal]
2437
+ if not same:
2438
+ return
2439
+ self.undo.set_props(
2440
+ arrange.align_to(scans[0], same, self._curve_of), "align")
2441
+
2442
+ def reset_offsets(self):
2443
+ """The selected scans back on zero - only those. With nothing
2444
+ selected this does nothing: resetting every offset of a stack
2445
+ because nothing was picked was one keystroke from losing it."""
2446
+ scans = self.doc.selected_scans()
2447
+ if not scans:
2448
+ return None
2449
+ return self.undo.set_props([(s, "offset", 0.0) for s in scans],
2450
+ "reset offsets")
2451
+
2452
+ def selected_artists(self):
2453
+ """The selected things drawn on the figure that are not data."""
2454
+ return [o for o in self.doc.selected()
2455
+ if self.plot.is_artist(o) and o.visible]
2456
+
2457
+ def align_artists(self, edge):
2458
+ """Line the selected artists up by `edge`: "left", "right", "top",
2459
+ "bottom" (to the outermost one), "centre" or "middle" (to the
2460
+ middle of them all), by their boxes as drawn. One undo step."""
2461
+ artists = self.selected_artists()
2462
+ if len(artists) < 2:
2463
+ return None
2464
+ plot = self.plot
2465
+ rect = plot.plot_rect()
2466
+ boxes = [(a, plot.rotated_bounds(a, plot.artist_box(a, rect), rect))
2467
+ for a in artists]
2468
+ left = min(b.left() for _a, b in boxes)
2469
+ right = max(b.right() for _a, b in boxes)
2470
+ top = min(b.top() for _a, b in boxes)
2471
+ bottom = max(b.bottom() for _a, b in boxes)
2472
+ changes = []
2473
+ for artist, box in boxes:
2474
+ dx = {"left": left - box.left(), "right": right - box.right(),
2475
+ "centre": (left + right) / 2.0 - box.center().x()
2476
+ }.get(edge, 0.0)
2477
+ dy = {"top": top - box.top(), "bottom": bottom - box.bottom(),
2478
+ "middle": (top + bottom) / 2.0 - box.center().y()
2479
+ }.get(edge, 0.0)
2480
+ old = (artist.x, artist.y)
2481
+ x, y = plot.artist_point(artist, rect)
2482
+ plot.set_artist_point(artist, x + dx, y + dy, rect, clamp=False)
2483
+ new = (artist.x, artist.y)
2484
+ artist.x, artist.y = old
2485
+ if new != old:
2486
+ changes += [(artist, "x", new[0]), (artist, "y", new[1])]
2487
+ self.undo.set_props(changes, "align {}".format(edge))
2488
+ self.note.setText("{} artists aligned: {}".format(len(artists), edge))
2489
+ return len(artists)
2490
+
2491
+ def stack_objects(self):
2492
+ """Everything with a place in the stack, bottom first. The weight
2493
+ curves too: drawn by `z_of` like the rest, and left out here they
2494
+ kept z None - drawn at 5 - while a restack numbered everything else
2495
+ from 0, which put a weight over the legend."""
2496
+ doc = self.doc
2497
+ objs = (list(doc.scans)
2498
+ + doc.analyses()
2499
+ + ([s.marker for s in doc.scans] if doc.offset_markers
2500
+ else [])
2501
+ + [doc.arrow, doc.legend] + list(doc.labels)
2502
+ + list(getattr(doc, "images", []))
2503
+ + list(getattr(doc, "structures", [])))
2504
+ order = dict((id(o), i) for i, o in enumerate(objs))
2505
+ return sorted(objs, key=lambda o: (model.z_of(o), order[id(o)]))
2506
+
2507
+ def layered_selected(self):
2508
+ """The selected objects that have a place in the stack."""
2509
+ return [o for o in self.stack_objects() if o.selected]
2510
+
2511
+ def restack(self, how):
2512
+ """Move the selected objects in the stack: "front", "back",
2513
+ "forward" or "backward" (past the next one that is not selected).
2514
+ The whole stack is then numbered in order - one undo step."""
2515
+ stack = self.stack_objects()
2516
+ chosen = set(id(o) for o in stack if o.selected)
2517
+ if not chosen:
2518
+ return None
2519
+ if how == "front":
2520
+ stack = ([o for o in stack if id(o) not in chosen]
2521
+ + [o for o in stack if id(o) in chosen])
2522
+ elif how == "back":
2523
+ stack = ([o for o in stack if id(o) in chosen]
2524
+ + [o for o in stack if id(o) not in chosen])
2525
+ elif how == "forward":
2526
+ for i in range(len(stack) - 2, -1, -1):
2527
+ if id(stack[i]) in chosen and id(stack[i + 1]) not in chosen:
2528
+ stack[i], stack[i + 1] = stack[i + 1], stack[i]
2529
+ elif how == "backward":
2530
+ for i in range(1, len(stack)):
2531
+ if id(stack[i]) in chosen and id(stack[i - 1]) not in chosen:
2532
+ stack[i], stack[i - 1] = stack[i - 1], stack[i]
2533
+ changes = [(o, "z", float(i)) for i, o in enumerate(stack)
2534
+ if o.z != float(i)]
2535
+ self.undo.set_props(changes, "stack order")
2536
+ return len(chosen)
2537
+
2538
+ def rotatable_selected(self):
2539
+ return any(self.plot.can_transform("rotate", o)
2540
+ for o in self.doc.selected())
2541
+
2542
+ def r_key(self):
2543
+ """R: scans selected, their offsets to zero; otherwise a selected
2544
+ label or legend is rotated (the arrow is not: its direction is what
2545
+ it says)."""
2546
+ if self.doc.selected_scans():
2547
+ return self.reset_offsets()
2548
+ if self.rotatable_selected():
2549
+ return self.plot.start_rotate()
2550
+ return None
2551
+
2552
+ # --------------------------------------------------------------- objects
2553
+ def settings_group(self, obj):
2554
+ """The other selected objects a settings dialog on `obj` edits too:
2555
+ the ones of its own kind, when `obj` is part of the selection."""
2556
+ if obj is None or not getattr(obj, "selected", False):
2557
+ return []
2558
+ return [o for o in self.doc.selected()
2559
+ if o is not obj and type(o) is type(obj)]
2560
+
2561
+ def edit_object(self, obj=None, part=None):
2562
+ """The settings of `obj` (the selection's first without one). An
2563
+ axis has three: `part` "spine" (ticks), "numbers" or "caption" -
2564
+ what was double-clicked, when not given."""
2565
+ if obj is None:
2566
+ chosen = self.doc.selected()
2567
+ obj = chosen[0] if chosen else None
2568
+ if isinstance(obj, model.Sample):
2569
+ return self.edit_sample(obj)
2570
+ group = self.settings_group(obj)
2571
+ if isinstance(obj, model.Scan):
2572
+ dialog = ScanSettings(self, obj, self.doc.unit_for(obj),
2573
+ on_change=self._live_change)
2574
+ elif isinstance(obj, model.Axis):
2575
+ # Three windows: the SPINE opens the ticks, the NUMBERS their size
2576
+ # and format, the CAPTION the caption. Which was double-clicked is
2577
+ # what the plot picked.
2578
+ part = part or self.plot.axis_hit() or "spine"
2579
+ kind = {"caption": CaptionSettings,
2580
+ "numbers": NumberSettings}.get(part, AxisSettings)
2581
+ dialog = kind(self, obj, self.doc, on_change=self._live_change)
2582
+ elif isinstance(obj, model.TextLabel):
2583
+ dialog = LabelSettings(self, obj, on_change=self._live_change)
2584
+ elif isinstance(obj, model.Legend):
2585
+ dialog = LegendSettings(self, obj, on_change=self._live_change)
2586
+ elif isinstance(obj, model.Analysis):
2587
+ dialog = AnalysisSettings(self, obj, on_change=self._live_change)
2588
+ if self.plot.editing() is obj and not group:
2589
+ # Its gizmos are up: the dialog opens BESIDE them, and
2590
+ # closing it - any way - confirms the interval and ends them.
2591
+ avoid = self.plot.gizmo_rect()
2592
+ if avoid is not None:
2593
+ self.place_beside(dialog, avoid)
2594
+ self._editing = (obj, dialog)
2595
+ dialog.finished.connect(
2596
+ lambda _result, a=obj: self._editing_closed(a))
2597
+ elif isinstance(obj, model.HeatFlowArrow):
2598
+ dialog = ArrowSettings(self, obj, on_change=self._live_change)
2599
+ elif isinstance(obj, model.OffsetMarker):
2600
+ dialog = OffsetMarkerSettings(self, obj,
2601
+ on_change=self._live_change)
2602
+ elif isinstance(obj, model.ImageArtist):
2603
+ dialog = ImageSettings(self, obj, on_change=self._live_change)
2604
+ elif isinstance(obj, model.MoleculeArtist):
2605
+ dialog = MoleculeSettings(self, obj, on_change=self._live_change)
2606
+ else:
2607
+ return None
2608
+ if group:
2609
+ dialog.set_group(group)
2610
+ self.note.setText("Settings for {} objects: what they share is "
2611
+ "set for all of them".format(len(group) + 1))
2612
+ self._run_live_dialog(dialog, obj, "settings")
2613
+ return obj
2614
+
2615
+ def _measure_ready(self, scan, x0, x1, editing, span=None):
2616
+ """Two cursors are down: ask what to compute, then compute it.
2617
+
2618
+ From the double-click-drag this runs the moment the button comes up,
2619
+ so the analysis is finished by the time the list closes - nothing is
2620
+ left to confirm. Cancelling the list drops that interval entirely:
2621
+ the gesture is one movement, and redoing it is cheaper than tidying
2622
+ up after it. Cursors placed with `C` are kept on a cancel instead,
2623
+ so Esc still steps back through them one at a time.
2624
+ """
2625
+ measuring = self.plot.measuring() or {}
2626
+ if editing is not None:
2627
+ # An analysis that already exists keeps ITS model: adjusting an
2628
+ # interval is not a chance to change what is being measured, and
2629
+ # being asked again every time is the annoyance.
2630
+ return self.remeasure(editing, x0, x1, span)
2631
+ # `ask_analysis` keeps its two arguments (tests stand in for it);
2632
+ # which curve it is for, it reads here.
2633
+ self._analysis_scan = scan
2634
+ chosen = self.ask_analysis(x0, x1)
2635
+ if not chosen:
2636
+ if measuring.get("gesture"):
2637
+ self.plot.end_measure()
2638
+ return None
2639
+ analysis = measure.run(chosen, scan, x0, x1, span=span)
2640
+ if analysis is None:
2641
+ self.note.setText(
2642
+ "{} could not be computed between those cursors".format(
2643
+ chosen))
2644
+ if measuring.get("gesture"):
2645
+ self.plot.end_measure()
2646
+ return None
2647
+
2648
+ def undo_it():
2649
+ if analysis in scan.analysis_objects:
2650
+ scan.analysis_objects.remove(analysis)
2651
+
2652
+ def redo_it():
2653
+ if analysis not in scan.analysis_objects:
2654
+ scan.analysis_objects.append(analysis)
2655
+
2656
+ self.undo.push(undo.CallCommand(
2657
+ redo_it, undo_it, "measure {}".format(analysis.model_name)))
2658
+ self.plot.end_measure()
2659
+ self.note.setText("{}: {}".format(analysis.model_name,
2660
+ analysis.summary()))
2661
+ self.refresh()
2662
+ return analysis
2663
+
2664
+ # --------------------------------------------- mass at a temperature
2665
+ def mass_targets(self):
2666
+ """The mass curves a marker goes on: the selected ones, else every
2667
+ one shown."""
2668
+ shown = [s for s in self.doc.scans if s.is_mass and s.visible]
2669
+ chosen = [s for s in shown if s.selected]
2670
+ return chosen or shown
2671
+
2672
+ def add_mass_markers(self, scans, temperatures):
2673
+ """A mass-at-temperature marker on each of `scans` at each of
2674
+ `temperatures` (degC), ONE undo step; the made ones, in order. A
2675
+ temperature a curve does not reach is skipped and said."""
2676
+ made, missed = [], 0
2677
+ for scan in scans:
2678
+ for celsius in temperatures:
2679
+ analysis = measure.run(measure.MASS_AT, scan, float(celsius),
2680
+ float(celsius))
2681
+ if analysis is None:
2682
+ missed += 1
2683
+ else:
2684
+ made.append((scan, analysis))
2685
+ if not made:
2686
+ self.note.setText("No curve reaches those temperatures")
2687
+ return []
2688
+
2689
+ def undo_it():
2690
+ for scan, analysis in made:
2691
+ if analysis in scan.analysis_objects:
2692
+ scan.analysis_objects.remove(analysis)
2693
+
2694
+ def redo_it():
2695
+ for scan, analysis in made:
2696
+ if analysis not in scan.analysis_objects:
2697
+ scan.analysis_objects.append(analysis)
2698
+
2699
+ self.undo.push(undo.CallCommand(
2700
+ redo_it, undo_it, "mass at {} temperature(s)".format(
2701
+ len(temperatures))))
2702
+ self.note.setText("{} mass marker(s){}".format(
2703
+ len(made), "; {} temperature(s) off a curve".format(missed)
2704
+ if missed else ""))
2705
+ self.refresh()
2706
+ return [analysis for _scan, analysis in made]
2707
+
2708
+ def ask_mass_temperatures(self):
2709
+ """F3 "Mass at temperatures...": type them, in the axis unit (98 F,
2710
+ 371 K convert); separated by ", ", ";" or spaces - a comma with no
2711
+ space is a decimal point."""
2712
+ scans = self.mass_targets()
2713
+ if not scans:
2714
+ return []
2715
+ from PySide6.QtWidgets import QInputDialog as _Input
2716
+ text, ok = _Input.getText(
2717
+ self, "Mass at temperatures",
2718
+ "Temperatures for {} mass curve(s), e.g. 261, 360, 480, 630:"
2719
+ .format(len(scans)), text=getattr(self, "_mass_typed", ""))
2720
+ if not ok or not text.strip():
2721
+ return []
2722
+ temperatures = parse_temperatures(text, self.doc.x_unit)
2723
+ if temperatures is None:
2724
+ self.note.setText("Not temperatures: {}".format(text.strip()))
2725
+ return []
2726
+ self._mass_typed = text.strip()
2727
+ return self.add_mass_markers(scans, temperatures)
2728
+
2729
+ def mass_here(self, scan, at):
2730
+ """The right-click's "Mass at this temperature": at the sample of
2731
+ `scan` nearest the point `at` (figure units) - on that curve and on
2732
+ every other SELECTED mass curve, at the same temperature."""
2733
+ trace = self.plot._trace_of(scan)
2734
+ index = self.plot.sample_at(trace, at) if trace is not None else None
2735
+ if index is None:
2736
+ return []
2737
+ temperature = float(scan.temperature()[index])
2738
+ targets = [scan] + [s for s in self.doc.scans
2739
+ if s.is_mass and s.visible and s.selected
2740
+ and s is not scan]
2741
+ return self.add_mass_markers(targets, [temperature])
2742
+
2743
+ def remeasure(self, analysis, x0, x1, span=None):
2744
+ """Move an analysis made here to a new interval, IN PLACE.
2745
+
2746
+ The same object, with new result fields: its styling stays, and so
2747
+ does everything holding it - the open settings dialog, the outliner
2748
+ row, the selection. It used to be replaced by a fresh object, which
2749
+ left an open dialog editing an analysis no longer in the figure, and
2750
+ kept the old label, number and all. A default label is rewritten
2751
+ with the new number; one somebody typed is kept (`measure.relabelled`).
2752
+
2753
+ The gizmos stay up while the analysis's settings are open (they are
2754
+ how the interval is adjusted) and go when they are not.
2755
+ """
2756
+ fields = measure.compute(analysis.model_name, analysis.scan, x0, x1,
2757
+ span)
2758
+ if not fields:
2759
+ self.note.setText(
2760
+ "{} could not be computed between those cursors".format(
2761
+ analysis.model_name))
2762
+ return None
2763
+ old_label = analysis.label
2764
+ new_label = measure.relabelled(analysis, fields)
2765
+ changes = [(analysis, "fields", fields),
2766
+ (analysis, "span", measure.clean_span(span))]
2767
+ if new_label != old_label:
2768
+ changes.append((analysis, "label", new_label))
2769
+ changes.extend(self._now_measured_here(analysis))
2770
+ self.undo.set_props(changes, "re-measure {}".format(
2771
+ analysis.model_name))
2772
+ dialog = self._editing_dialog(analysis)
2773
+ if dialog is not None:
2774
+ dialog.adopt_measurement(old_label, new_label)
2775
+ elif self.plot.editing() is analysis:
2776
+ self.plot.end_measure()
2777
+ self.note.setText("{}: {}".format(analysis.model_name,
2778
+ analysis.summary(self.doc)))
2779
+ return analysis
2780
+
2781
+ @staticmethod
2782
+ def _now_measured_here(analysis):
2783
+ """A file's analysis recomputed here is the panel's from now on:
2784
+ its numbers no longer come from TRIOS, and the session must store
2785
+ how to make it again."""
2786
+ if analysis.source == "panel":
2787
+ return []
2788
+ return [(analysis, "source", "panel"),
2789
+ (analysis, "attribution", "measured here")]
2790
+
2791
+ def retype_interval(self, analysis, start, end):
2792
+ """The interval typed in the settings, in degC: recompute there.
2793
+
2794
+ On an analysis measured along the curve the typed temperature is
2795
+ found by WALKING the segment from where that cursor was, so it
2796
+ stays on the same branch of a curve that doubles back; otherwise
2797
+ the temperatures are the interval. One undo step, like a gizmo
2798
+ drag. Returns the analysis, or None when it cannot be computed.
2799
+ """
2800
+ scan = analysis.scan
2801
+ span = analysis.span
2802
+ if span:
2803
+ temperature = scan.temperature()
2804
+ lo, hi = scan.kept_range(len(temperature))
2805
+ first = measure.walk_to(temperature, span[0], start, lo, hi)
2806
+ last = measure.walk_to(temperature, span[1], end, lo, hi)
2807
+ span = (first, last) if first != last else None
2808
+ if span is not None:
2809
+ start = float(temperature[first])
2810
+ end = float(temperature[last])
2811
+ low, high = sorted((start, end))
2812
+ done = self.remeasure(analysis, low, high, span)
2813
+ if done is not None and self.plot.editing() is analysis:
2814
+ self.plot.set_measure_cursors([low, high], span)
2815
+ return done
2816
+
2817
+ def change_model(self, analysis, name):
2818
+ """Make `analysis` another kind (onset -> endset, ...) on the same
2819
+ interval, IN PLACE and as one undo step. False when it cannot be
2820
+ computed there, or has no interval to compute on."""
2821
+ cursors = analysis.cursors()
2822
+ if len(cursors) != 2:
2823
+ self.note.setText("This analysis has no interval to recompute on")
2824
+ return False
2825
+ fields = measure.compute(name, analysis.scan, cursors[0], cursors[1],
2826
+ analysis.span)
2827
+ if not fields:
2828
+ self.note.setText("{} could not be computed on that "
2829
+ "interval".format(name))
2830
+ return False
2831
+ model_name = fields.get("Model", name)
2832
+ changes = [(analysis, "model_name", model_name),
2833
+ (analysis, "name", model_name),
2834
+ (analysis, "fields", fields)]
2835
+ changes.extend(self._now_measured_here(analysis))
2836
+ self.undo.set_props(changes, "make it {}".format(model_name))
2837
+ self.note.setText("{}: {}".format(model_name,
2838
+ analysis.summary(self.doc)))
2839
+ return True
2840
+
2841
+ def _editing_dialog(self, analysis):
2842
+ """The open settings dialog that owns this analysis's gizmos."""
2843
+ editing = getattr(self, "_editing", None)
2844
+ if editing is None or editing[0] is not analysis:
2845
+ return None
2846
+ return editing[1] if editing[1].isVisible() else None
2847
+
2848
+ def _editing_closed(self, analysis):
2849
+ """Its settings closed - by OK, Cancel or the window's X: confirm.
2850
+
2851
+ The gizmos exist only while the settings are open. An interval
2852
+ moved and not yet confirmed is taken now, and the gizmos go.
2853
+ """
2854
+ self._editing = None
2855
+ state = self.plot.measuring()
2856
+ if state is None or state.get("editing") is not analysis:
2857
+ return
2858
+ cursors = sorted(float(c) for c in state["cursors"])
2859
+ stored = sorted(analysis.cursors())
2860
+ span = measure.clean_span(state.get("span"))
2861
+ if len(cursors) == 2 and (span != analysis.span or len(stored) != 2
2862
+ or any(abs(a - b) > 1e-6
2863
+ for a, b in zip(cursors, stored))):
2864
+ self.remeasure(analysis, cursors[0], cursors[1], span)
2865
+ self.plot.end_measure()
2866
+
2867
+ def place_beside(self, dialog, avoid):
2868
+ """Move `dialog` next to - never over - `avoid`, a global rect.
2869
+
2870
+ The side with more room on the screen, top-aligned with the plot, and
2871
+ pulled back on screen if it does not fit. Its frame is not known
2872
+ before it is shown, so a margin stands in for the title bar.
2873
+ """
2874
+ from PySide6.QtGui import QGuiApplication
2875
+ dialog.adjustSize()
2876
+ width = dialog.width() + 16
2877
+ height = dialog.height() + 40
2878
+ screen = (QGuiApplication.screenAt(avoid.center())
2879
+ or self.screen() or QGuiApplication.primaryScreen())
2880
+ area = screen.availableGeometry()
2881
+ room_left = avoid.left() - area.left()
2882
+ room_right = area.right() - avoid.right()
2883
+ if room_right >= width + 12 or room_right >= room_left:
2884
+ x = avoid.right() + 12
2885
+ else:
2886
+ x = avoid.left() - 12 - width
2887
+ x = max(area.left(), min(x, area.right() - width))
2888
+ y = max(area.top(), min(avoid.top(), area.bottom() - height))
2889
+ dialog.move(x, y)
2890
+ return QPoint(x, y)
2891
+
2892
+ def ask_analysis(self, x0, x1):
2893
+ """Which analysis to run between two temperatures, or None.
2894
+
2895
+ The quick list opens WHERE THE POINTER IS - the button just came up
2896
+ over the curve, and a list in the middle of the window is a trip
2897
+ across the screen for every analysis. Kept a method of its own so a
2898
+ test can answer it without reaching a modal event loop.
2899
+ """
2900
+ unit = units.TEMPERATURE_LABEL.get(self.doc.x_unit, self.doc.x_unit)
2901
+ low, high = sorted((self.plot.to_axis(x0), self.plot.to_axis(x1)))
2902
+ scan = getattr(self, "_analysis_scan", None)
2903
+ dialog = MeasurePalette(measure.models_for(scan) if scan is not None
2904
+ else measure.MODELS, self)
2905
+ dialog.setWindowTitle("Analyse {:.1f} - {:.1f} {}".format(low, high,
2906
+ unit))
2907
+ dialog.place_at(QCursor.pos())
2908
+ if not dialog.exec() or not dialog.chosen:
2909
+ return None
2910
+ return dialog.chosen
2911
+
2912
+ def set_x_unit(self, unit):
2913
+ """Draw the x axis in Celsius, Kelvin or Fahrenheit.
2914
+
2915
+ A display conversion only: the data, the stored analyses and anything
2916
+ measured here all stay in the file's own Celsius, so switching back
2917
+ and forth cannot drift.
2918
+ """
2919
+ self.doc.x_unit = unit
2920
+ for scan in self.doc.scans:
2921
+ scan._cache_key = None
2922
+ self.refresh(keep_view=False)
2923
+ self.note.setText("X axis in {}".format(
2924
+ units.TEMPERATURE_LABEL.get(unit, unit)))
2925
+
2926
+ def flush_targets(self):
2927
+ """The analyses Ctrl+L / Ctrl+R / Ctrl+M act on.
2928
+
2929
+ The selected analyses; with none selected, every analysis shown on
2930
+ the selected scans, so one key lines up all the labels of a curve.
2931
+ """
2932
+ chosen = [obj for obj in self.doc.selected()
2933
+ if isinstance(obj, model.Analysis)]
2934
+ if chosen:
2935
+ return chosen
2936
+ return [analysis for scan in self.doc.selected_scans()
2937
+ for analysis in scan.visible_analyses()]
2938
+
2939
+ def set_flush(self, side):
2940
+ """Align analysis labels left, right or centred on their arrows,
2941
+ as one undo step."""
2942
+ targets = self.flush_targets()
2943
+ texts = [o for o in self.doc.selected()
2944
+ if isinstance(o, model.TextLabel)]
2945
+ if not targets and not texts:
2946
+ self.note.setText("Select a label, a note or an analysis label "
2947
+ "first")
2948
+ return 0
2949
+ changes = [(a, "flush", side) for a in targets]
2950
+ for label in texts:
2951
+ changes += self.plot.note_flush_changes(
2952
+ label, style.FLUSH_CENTER if side == style.FLUSH_CENTER
2953
+ else side)
2954
+ self.undo.set_props(changes, "align labels")
2955
+ self.note.setText("{} label(s) aligned {}".format(
2956
+ len(targets) + len(texts), style.FLUSH_TITLES.get(side, side)))
2957
+ return len(targets) + len(texts)
2958
+
2959
+ # --------------------------------------------------------------- x range
2960
+ def x_range_dialog(self):
2961
+ """The `M` pop-up, filled with the ranges on screen (not shown):
2962
+ x first, then y - Tab reaches y only when it is wanted."""
2963
+ lo, hi = self.plot.view_x()
2964
+ if self.doc.x_axis == model.AXIS_TEMPERATURE:
2965
+ unit = units.TEMPERATURE_LABEL.get(self.doc.x_unit, "")
2966
+ else:
2967
+ unit = "min"
2968
+ y = y2 = None
2969
+ if self.plot.heat_shown():
2970
+ y_lo, y_hi = self.plot.view_y()
2971
+ y = (self.doc.y_axis_unit(), y_lo, y_hi)
2972
+ if self.plot.mass_shown():
2973
+ w_lo, w_hi = self.plot.view_y2()
2974
+ y2 = (self.doc.weight_unit, w_lo, w_hi)
2975
+ return RangeDialog("Range", unit, lo, hi, self, y=y, y2=y2)
2976
+
2977
+ def ask_x_range(self):
2978
+ dialog = self.x_range_dialog()
2979
+ if not dialog.exec():
2980
+ return None
2981
+ return self.set_x_range(*dialog.values(), y=dialog.y_values(),
2982
+ y2=dialog.y2_values())
2983
+
2984
+ def set_x_range(self, lo, hi, y=None, y2=None):
2985
+ """Frame x from `lo` to `hi` (axis units), and y to the pair `y`
2986
+ and the weight axis to `y2` when they differ from what is shown -
2987
+ as ONE undo step."""
2988
+ if not hi > lo:
2989
+ return None
2990
+ self.plot.commit_view()
2991
+ self.plot._view_begin("range")
2992
+ self.plot.set_view_x(lo, hi)
2993
+ if y and y[1] > y[0] and not _same_pair(y, self.plot.view_y()):
2994
+ self.plot.set_view_y(*y)
2995
+ if (y2 and y2[1] > y2[0]
2996
+ and not _same_pair(y2, self.plot.view_y2())):
2997
+ self.plot.set_view_y2(*y2)
2998
+ self.plot.commit_view()
2999
+ self.note.setText("x from {:g} to {:g}".format(lo, hi))
3000
+ return (lo, hi)
3001
+
3002
+ # ---------------------------------------------------- y-offset markers
3003
+ def toggle_offset_markers(self):
3004
+ """The template's `add_yoffset_markers`, on or off, for the
3005
+ SELECTED scans - every scan when none is selected. One undo step.
3006
+
3007
+ Shown when any of those has none; hidden when they all have one.
3008
+ Markers first shown for a selection go on those scans alone; the
3009
+ figure's switch (`Document.offset_markers`) goes off with the last
3010
+ marker, and each marker's own `visible` (H) says which scans have
3011
+ one."""
3012
+ doc = self.doc
3013
+ chosen = [s for s in doc.selected_scans() if s.visible]
3014
+ scans = chosen or list(doc.scans)
3015
+ was_on = doc.offset_markers
3016
+ changes = []
3017
+ shown = not (was_on and scans and all(s.marker.visible
3018
+ for s in scans))
3019
+ if shown:
3020
+ if not was_on:
3021
+ changes.append((doc, "offset_markers", True))
3022
+ for scan in doc.scans:
3023
+ want = (True if scan in scans
3024
+ else scan.marker.visible and was_on)
3025
+ if scan.marker.visible != want:
3026
+ changes.append((scan.marker, "visible", want))
3027
+ else:
3028
+ left = [s for s in doc.scans
3029
+ if s not in scans and s.marker.visible and s.visible]
3030
+ if left:
3031
+ changes += [(s.marker, "visible", False) for s in scans]
3032
+ else:
3033
+ changes.append((doc, "offset_markers", False))
3034
+ if changes:
3035
+ self.undo.set_props(changes, "y-offset markers")
3036
+ self.note.setText("Y-offset markers {} {}".format(
3037
+ "shown on" if shown else "hidden on",
3038
+ "the {} selected scan(s)".format(len(chosen)) if chosen
3039
+ else "every scan"))
3040
+
3041
+ def select_offset_markers(self):
3042
+ """Every drawn scan's marker, so they can be moved or styled as one."""
3043
+ self.doc.select_only([s.marker for s in self.doc.visible_scans()])
3044
+ self._selection_changed()
3045
+ self.plot.update()
3046
+
3047
+ def toggle_legend(self):
3048
+ """Put the key on the figure, or take it off. Undoable like the rest."""
3049
+ legend = self.doc.legend
3050
+ self.undo.set_props([(legend, "visible", not legend.visible)],
3051
+ "legend")
3052
+ self.note.setText("Legend {}".format(
3053
+ "shown" if legend.visible else "hidden"))
3054
+
3055
+ def add_label(self, text=None, at=None, scan=None):
3056
+ """Put a caption on the figure, undoably.
3057
+
3058
+ Placed where the cursor is, or in the middle when there is no cursor
3059
+ (F3, a menu). It is an object from the moment it exists: draggable,
3060
+ editable, selectable, saved with the session.
3061
+ """
3062
+ from PySide6.QtWidgets import QInputDialog as _Input
3063
+ if text is None:
3064
+ text, ok = _Input.getText(self, "Add a label", "Text:")
3065
+ if not ok or not text.strip():
3066
+ return None
3067
+ text = text.strip()
3068
+ rect = self.plot.plot_rect()
3069
+ cursor = at or self.plot._cursor
3070
+ if cursor is not None:
3071
+ x, y = self.plot.px_to_rel(cursor.x(), cursor.y(), rect)
3072
+ else:
3073
+ x = y = 0.5
3074
+ label = model.TextLabel(self.doc._next_id(), text,
3075
+ min(0.98, max(0.02, x)),
3076
+ min(0.98, max(0.02, y)), scan)
3077
+ if scan is not None:
3078
+ hung = self.plot.attachment(label)
3079
+ if hung is not None:
3080
+ label.at, label.dx, label.dy = hung
3081
+ self.undo.push(undo.CallCommand(
3082
+ lambda: self.doc.labels.append(label),
3083
+ lambda: self.doc.remove_label(label),
3084
+ "add a label"))
3085
+ self.refresh()
3086
+ return label
3087
+
3088
+ def add_note(self, text=None, at=None):
3089
+ """A NOTE: a label with an arrow to a point.
3090
+
3091
+ The arrow's tip goes where the pointer is (or `at`, figure units);
3092
+ on a curve, onto the curve, and the note then belongs to that scan,
3093
+ so it moves with it. The text starts up and to the right of the
3094
+ tip. The tip is moved by its handle while the note is selected, and
3095
+ snaps onto a curve near it. One undo step.
3096
+ """
3097
+ from PySide6.QtWidgets import QInputDialog as _Input
3098
+ if text is None:
3099
+ text, ok = _Input.getText(self, "Add a note", "Text:")
3100
+ if not ok or not text.strip():
3101
+ return None
3102
+ text = text.strip()
3103
+ plot = self.plot
3104
+ rect = plot.plot_rect()
3105
+ tip = QPointF(at if at is not None
3106
+ else (plot._cursor or rect.center()))
3107
+ near = plot.curve_point_near(tip)
3108
+ scan = None
3109
+ if near is not None:
3110
+ tip, trace = near
3111
+ scan = trace.scan
3112
+ # Straight above the point, the arrow pointing DOWN at it; below it
3113
+ # where there is no room above.
3114
+ above = tip.y() - 40.0 >= rect.top() + 20.0
3115
+ corner_x = tip.x()
3116
+ corner_y = tip.y() - 40.0 if above else tip.y() + 40.0
3117
+ fx, fy = plot.px_to_rel(corner_x, corner_y, rect)
3118
+ label = model.TextLabel(self.doc._next_id(), text, fx, fy, scan)
3119
+ label.anchor = "bottom" if above else "top"
3120
+ label.leader = plot.leader_value(label, tip, rect)
3121
+ if scan is not None:
3122
+ # On a curve it hangs from it, straight above (or below) its
3123
+ # point, like an analysis label.
3124
+ index = plot.sample_at(plot._trace_of(scan), tip, rect)
3125
+ if index is not None:
3126
+ label.at = ("i", int(index))
3127
+ label.dx = 0.0
3128
+ label.dy = plot.NOTE_DY if above else -plot.NOTE_DY
3129
+ self.undo.push(undo.CallCommand(
3130
+ lambda: self.doc.labels.append(label),
3131
+ lambda: self.doc.remove_label(label),
3132
+ "add a note"))
3133
+ self.refresh()
3134
+ return label
3135
+
3136
+ def add_marker_line(self, text=None, at=None):
3137
+ """A MARKER LINE: a dashed vertical line across the axes at the
3138
+ pointer's temperature (or `at`, figure units), with its text turned
3139
+ upright on it - a melting point, a decomposition. A label underneath:
3140
+ moved sideways it moves the line, up and down the text slides along it.
3141
+ One undo step."""
3142
+ if self.doc.x_axis != model.AXIS_TEMPERATURE:
3143
+ self.note.setText("A marker line needs the temperature axis")
3144
+ return None
3145
+ from PySide6.QtWidgets import QInputDialog as _Input
3146
+ if text is None:
3147
+ text, ok = _Input.getText(self, "Add a marker line", "Text:")
3148
+ if not ok:
3149
+ return None
3150
+ text = text.strip()
3151
+ plot = self.plot
3152
+ rect = plot.plot_rect()
3153
+ where = QPointF(at if at is not None
3154
+ else (plot._cursor or rect.center()))
3155
+ celsius = float(units.to_celsius(plot.px_to_x(where.x(), rect),
3156
+ self.doc.x_unit))
3157
+ label = model.TextLabel(
3158
+ self.doc._next_id(), text or " ",
3159
+ (where.x() - rect.left()) / max(1.0, rect.width()), 0.72)
3160
+ label.vline = celsius
3161
+ label.rotation = 90.0
3162
+ label.anchor = "center"
3163
+ self.undo.push(undo.CallCommand(
3164
+ lambda: self.doc.labels.append(label),
3165
+ lambda: self.doc.remove_label(label),
3166
+ "add a marker line"))
3167
+ self.refresh()
3168
+ return label
3169
+
3170
+ # ------------------------------------------------------------ parenting
3171
+ def parent_labels(self, labels, scan):
3172
+ """Give labels to `scan` - or free them, `scan` None: a PARENTING
3173
+ operation. A label with a parent is listed under it, wears its
3174
+ colour while its own is automatic, goes with it when it is removed
3175
+ and moves with it when it is offset. Each label
3176
+ stays exactly where it is drawn (Blender's "keep transform"). One
3177
+ undo step."""
3178
+ changes = []
3179
+ moved = 0
3180
+ for label in labels:
3181
+ if not isinstance(label, model.TextLabel) or label.scan is scan:
3182
+ continue
3183
+ # Given to a scan, it hangs from the curve where it stands;
3184
+ # freed, it keeps its place on the page.
3185
+ hung = (self.plot.attachment(label, scan)
3186
+ if scan is not None and label.vline is None else None)
3187
+ x, y, followed, leader = self.plot.placed_under(label, scan)
3188
+ changes += [(label, "scan", scan), (label, "parent_offset",
3189
+ followed),
3190
+ (label, "x", x), (label, "y", y)]
3191
+ if leader != label.leader:
3192
+ changes.append((label, "leader", leader))
3193
+ if hung is not None:
3194
+ changes += [(label, "at", hung[0]), (label, "dx", hung[1]),
3195
+ (label, "dy", hung[2])]
3196
+ elif label.at is not None:
3197
+ changes.append((label, "at", None))
3198
+ moved += 1
3199
+ if not changes:
3200
+ return 0
3201
+ self.undo.set_props(changes, "free the labels" if scan is None
3202
+ else "give labels to a scan")
3203
+ self.note.setText(
3204
+ "{} label(s) freed".format(moved) if scan is None else
3205
+ "{} label(s) now belong to {}".format(moved, scan.display_name()))
3206
+ return moved
3207
+
3208
+ def parent_selected(self):
3209
+ """Ctrl+P: the selected labels to the ONE selected scan."""
3210
+ scans = self.doc.selected_scans()
3211
+ if len(scans) != 1:
3212
+ return 0
3213
+ return self.parent_labels(self.selected_labels(), scans[0])
3214
+
3215
+ def unparent_selected(self):
3216
+ """Ctrl+Shift+P: the selected labels free again."""
3217
+ return self.parent_labels(
3218
+ [lb for lb in self.selected_labels() if lb.scan is not None],
3219
+ None)
3220
+
3221
+ def selected_labels(self):
3222
+ return [o for o in self.doc.selected()
3223
+ if isinstance(o, model.TextLabel)]
3224
+
3225
+ def _label_menu(self, menu, label):
3226
+ """A label's right-click menu: its settings, and which scan it
3227
+ belongs to - every scan on the plot, or none."""
3228
+ act = menu.addAction("Label settings...")
3229
+ act.triggered.connect(lambda _c=False: self.edit_object(label))
3230
+ owner = menu.addMenu("Belongs to")
3231
+ free = owner.addAction("No scan (free)")
3232
+ free.setCheckable(True)
3233
+ free.setChecked(label.scan is None)
3234
+ free.triggered.connect(
3235
+ lambda _c=False: self.parent_labels([label], None))
3236
+ owner.addSeparator()
3237
+ for scan in self.doc.scans:
3238
+ entry = owner.addAction(scan.display_name())
3239
+ entry.setCheckable(True)
3240
+ entry.setChecked(label.scan is scan)
3241
+ entry.triggered.connect(
3242
+ (lambda s: lambda _c=False: self.parent_labels([label], s))(
3243
+ scan))
3244
+ hide = menu.addAction("Hide this label")
3245
+ hide.triggered.connect(lambda _c=False: self._set_visible(label, False))
3246
+ return owner
3247
+
3248
+ # --------------------------------------------------------- style presets
3249
+ def apply_preset(self, preset):
3250
+ """Put a style preset on this figure (`core/presets.py`): its
3251
+ style column and, when the preset carries them, its size and
3252
+ margins. One undo step; an object's own choices are left alone."""
3253
+ changes = presets.changes(self.doc, preset)
3254
+ if changes:
3255
+ self.undo.set_props(changes, "style preset {}".format(
3256
+ preset.name))
3257
+ if any(obj is self.doc.figure for obj, _a, _v in changes):
3258
+ self.plot.fit_page()
3259
+ self.note.setText("Style preset: {}".format(preset.name)
3260
+ if changes else
3261
+ "{} is already this figure's style".format(
3262
+ preset.name))
3263
+ return len(changes)
3264
+
3265
+ def ask_preset_name(self):
3266
+ """`(name, with_layout)` for a new preset, or None. A method of its
3267
+ own so a test can answer it without a modal loop."""
3268
+ found, _problems = presets.available()
3269
+ dialog = PresetSaveDialog(
3270
+ self, with_layout=(self.plot.layout_mode()
3271
+ != figure_module.MODE_WINDOW),
3272
+ taken=[p.name for p in found])
3273
+ if not dialog.exec():
3274
+ return None
3275
+ return dialog.name(), dialog.with_layout.isChecked()
3276
+
3277
+ def save_preset(self):
3278
+ """This figure's look as a preset, in the presets folder."""
3279
+ answer = self.ask_preset_name()
3280
+ if not answer:
3281
+ return None
3282
+ name, with_layout = answer
3283
+ preset = presets.from_figure(self.doc, name, with_layout)
3284
+ try:
3285
+ path = presets.save(preset)
3286
+ except OSError as exc:
3287
+ self.note.setText("Could not save the preset: {}".format(exc))
3288
+ return None
3289
+ self.plot.flash("Preset saved: {}".format(name))
3290
+ self.note.setText(path)
3291
+ return preset
3292
+
3293
+ def open_presets_folder(self):
3294
+ """Where the preset files are: to copy one to a colleague, or to
3295
+ take one out."""
3296
+ from PySide6.QtCore import QUrl
3297
+ from PySide6.QtGui import QDesktopServices
3298
+ folder = presets.folder()
3299
+ if not os.path.isdir(folder):
3300
+ os.makedirs(folder)
3301
+ QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
3302
+ self.note.setText(folder)
3303
+ return folder
3304
+
3305
+ def install_preset(self, path):
3306
+ """A dropped preset file: into the presets folder, and onto this
3307
+ figure."""
3308
+ try:
3309
+ preset = presets.install(path)
3310
+ except (ValueError, OSError) as exc:
3311
+ self.note.setText("Not a style preset: {}".format(exc))
3312
+ return None
3313
+ self.apply_preset(preset)
3314
+ return preset
3315
+
3316
+ def _fill_presets_menu(self, menu):
3317
+ """Edit > Style presets: every preset in the folder, then saving
3318
+ one and the folder. Rebuilt each time it opens, so a preset copied
3319
+ into the folder is there without a restart."""
3320
+ menu.clear()
3321
+ found, problems = presets.available()
3322
+ for preset in found:
3323
+ act = menu.addAction(preset.name)
3324
+ act.setToolTip("Size and margins too" if preset.layout
3325
+ else "Style only")
3326
+ act.triggered.connect(
3327
+ (lambda p: lambda _c=False: self.apply_preset(p))(preset))
3328
+ if not found:
3329
+ none = menu.addAction("(none yet: save this figure's style)")
3330
+ none.setEnabled(False)
3331
+ if problems:
3332
+ bad = menu.addAction("{} unreadable file(s) in the folder".format(
3333
+ len(problems)))
3334
+ bad.setEnabled(False)
3335
+ bad.setToolTip("\n".join(problems))
3336
+ menu.addSeparator()
3337
+ for op_id in ("style.preset_save", "style.preset_folder"):
3338
+ op = self.ops.get(op_id)
3339
+ act = menu.addAction(op.label)
3340
+ act.triggered.connect(
3341
+ (lambda o: lambda _c=False: self.run_op(o))(op_id))
3342
+ return menu
3343
+
3344
+ def choose_preset(self):
3345
+ """F3's "Apply a style preset...": the list, where the pointer is."""
3346
+ menu = self._fill_presets_menu(QMenu(self))
3347
+ menu.exec(QCursor.pos())
3348
+
3349
+ # ------------------------------------------------ the size in numbers
3350
+ def _page_margins(self, unit):
3351
+ """The margins a figure made exact would keep, in `unit`: its own
3352
+ when it already is exact, else the ones it is drawn with now (sized
3353
+ to its numbers), rounded UP to a hundredth so nothing overflows."""
3354
+ layout = self.doc.figure
3355
+ if self.plot.layout_mode() == figure_module.MODE_SIZE:
3356
+ factor = figure_module.PER_INCH[unit] / figure_module.PER_INCH[layout.unit]
3357
+ return tuple(getattr(layout, "margin_" + side) * factor
3358
+ for side in ("left", "right", "top", "bottom"))
3359
+ per = figure_module.PER_INCH[unit] / figure_module.DESIGN_DPI
3360
+ return tuple(math.ceil(px * per * 100.0 - 1e-9) / 100.0
3361
+ for px in self.plot.margins())
3362
+
3363
+ def page_size_dialog(self):
3364
+ """The size pop-up, filled with the page as it is (not shown)."""
3365
+ layout = self.doc.figure
3366
+ unit = layout.unit
3367
+ if self.plot.layout_mode() == figure_module.MODE_SIZE:
3368
+ width, height = layout.width, layout.height
3369
+ else:
3370
+ per = figure_module.PER_INCH[unit] / figure_module.DESIGN_DPI
3371
+ canvas_w, canvas_h = self.plot.canvas_size()
3372
+ width, height = canvas_w * per, canvas_h * per
3373
+ left, right, top, bottom = self._page_margins(figure_module.UNIT_CM)
3374
+ return PageSizeDialog(width, height, unit, self,
3375
+ least=(left + right, top + bottom))
3376
+
3377
+ def ask_page_size(self):
3378
+ dialog = self.page_size_dialog()
3379
+ if not dialog.exec() or dialog.values() is None:
3380
+ return None
3381
+ return self.set_page_size(*dialog.values())
3382
+
3383
+ def set_page_size(self, width, height, unit):
3384
+ """Make the figure EXACTLY `width` x `height` `unit`, keeping its
3385
+ margins (the ones it is drawn with, when it was not exact yet). One
3386
+ undo step; the page is fitted to the pane again."""
3387
+ layout = self.doc.figure
3388
+ new = layout.copy()
3389
+ margins = self._page_margins(unit)
3390
+ new.unit = unit
3391
+ (new.margin_left, new.margin_right, new.margin_top,
3392
+ new.margin_bottom) = margins
3393
+ new.width, new.height = float(width), float(height)
3394
+ new.mode = figure_module.MODE_SIZE
3395
+ if not new.is_valid():
3396
+ self.note.setText("Too small for the margins: {:g} x {:g} {}"
3397
+ .format(width, height, unit))
3398
+ return None
3399
+ changes = [(layout, name, getattr(new, name))
3400
+ for name in figure_module.FigureLayout.FIELDS
3401
+ if getattr(new, name) != getattr(layout, name)]
3402
+ if changes:
3403
+ self.undo.set_props(changes, "figure size")
3404
+ self.plot.fit_page()
3405
+ self.note.setText("Figure {:g} x {:g} {}".format(width, height, unit))
3406
+ return new
3407
+
3408
+ def edit_figure(self):
3409
+ """This session's figure size and margins (`core/figure.py`)."""
3410
+ dialog = FigureSettings(self, self.doc.figure, plot=self.plot,
3411
+ on_change=self._live_change)
3412
+ self._run_live_dialog(dialog, self.doc.figure, "figure size")
3413
+ return dialog
3414
+
3415
+ def edit_sample(self, sample):
3416
+ dialog = SampleSettings(self, sample, on_change=self._live_change)
3417
+ self._run_live_dialog(dialog, sample, "sample settings")
3418
+ return sample
3419
+
3420
+ def _run_live_dialog(self, dialog, obj, label):
3421
+ """Show a live dialog and turn what it did into ONE undo step.
3422
+
3423
+ The dialog has already written to the object a dozen times by the
3424
+ time OK is pressed, so the changes are read back, the object is put
3425
+ back the way it was, and the undo stack applies them again. That is
3426
+ the only way a live dialog and an undo stack can both be honest.
3427
+
3428
+ SHOWN, not `exec`ed: these are non-modal, so the plot stays live
3429
+ underneath and the undo step is built when the dialog finishes rather
3430
+ than when this function returns. The reference is kept, or Python
3431
+ collects the dialog the moment this returns.
3432
+ """
3433
+ before = dialog.snapshot()
3434
+ extra_before = None
3435
+ if isinstance(obj, model.Scan):
3436
+ extra_before = obj.sample.molar_mass
3437
+ self._dialogs = [d for d in getattr(self, "_dialogs", [])
3438
+ if d.isVisible()]
3439
+ self._dialogs.append(dialog)
3440
+ dialog.finished.connect(
3441
+ lambda result, d=dialog: self._live_dialog_done(
3442
+ d, obj, label, before, extra_before, result))
3443
+ dialog.show()
3444
+ dialog.raise_()
3445
+ return dialog
3446
+
3447
+ def _live_dialog_done(self, dialog, obj, label, before, extra_before,
3448
+ result):
3449
+ if not result:
3450
+ self._live_change()
3451
+ return
3452
+ # The dialog's snapshot as it is NOW: a re-measure while it was open
3453
+ # moves the "before" label on with the number (`adopt_measurement`),
3454
+ # so the settings step does not undo the label back to an old value.
3455
+ changes = []
3456
+ for target, before in dialog.snapshots():
3457
+ for name, old in before.items():
3458
+ new = getattr(target, name)
3459
+ if new != old:
3460
+ setattr(target, name, old)
3461
+ changes.append((target, name, new))
3462
+ if isinstance(obj, model.Scan):
3463
+ new = obj.sample.molar_mass
3464
+ if new != extra_before:
3465
+ obj.sample.molar_mass = extra_before
3466
+ changes.append((obj.sample, "molar_mass", new))
3467
+ if changes:
3468
+ self.undo.set_props(changes, label)
3469
+ else:
3470
+ self.refresh()
3471
+
3472
+ def _live_change(self):
3473
+ for scan in self.doc.scans:
3474
+ scan._cache_key = None
3475
+ self.refresh()
3476
+
3477
+ def set_analyses(self, on):
3478
+ """Switch every analysis of the selected scans on or off at once.
3479
+
3480
+ The per-analysis boxes are in the outliner and in the scan's
3481
+ settings; this is the "all of them" shortcut, for the scan that has
3482
+ one onset and one integration worth showing.
3483
+ """
3484
+ changes = []
3485
+ for scan in self.doc.selected_scans():
3486
+ for analysis in scan.analysis_objects:
3487
+ # One offered "by step name" is attributed by being shown on
3488
+ # ITS scan; "all of them" would put it on every scan whose
3489
+ # step has that name.
3490
+ if on and analysis.attribution == "by step name":
3491
+ continue
3492
+ changes.append((analysis, "visible", bool(on)))
3493
+ self.undo.set_props(changes,
3494
+ "show analyses" if on else "hide analyses")
3495
+
3496
+ def hide_selected(self):
3497
+ objs = [o for o in self.doc.selected() if o.visible]
3498
+ self.undo.set_props([(o, "visible", False) for o in objs], "hide")
3499
+
3500
+ def show_all(self):
3501
+ objs = [o for o in self.doc.objects() if not o.visible]
3502
+ self.undo.set_props([(o, "visible", True) for o in objs], "show all")
3503
+
3504
+ def remove_selected(self):
3505
+ """Delete: whatever is selected, in the order it makes sense.
3506
+
3507
+ An analysis or a caption goes on its own - deleting the scan under a
3508
+ selected analysis would be a surprise - and only a selection of scans
3509
+ removes scans.
3510
+ """
3511
+ # A file's own row in the outliner, with the outliner in hand:
3512
+ # Delete closes the file.
3513
+ files = self.files_to_close()
3514
+ if files:
3515
+ self.close_samples(files)
3516
+ return
3517
+ analyses = [o for o in self.doc.selected()
3518
+ if isinstance(o, model.Analysis)]
3519
+ labels = [o for o in self.doc.selected()
3520
+ if isinstance(o, (model.TextLabel, model.ImageArtist,
3521
+ model.MoleculeArtist))]
3522
+ if analyses or labels:
3523
+ self.remove_analyses(analyses, labels)
3524
+ return
3525
+ self.remove_scans(self.doc.selected_scans())
3526
+
3527
+ def remove_analyses(self, analyses=(), labels=()):
3528
+ """Take analyses and captions off the figure, undoably."""
3529
+ places = [(a, a.scan, a.scan.analysis_objects.index(a))
3530
+ for a in analyses if a in a.scan.analysis_objects]
3531
+ label_places = [(lb, self._shelf(lb).index(lb)) for lb in labels
3532
+ if lb in self._shelf(lb)]
3533
+ if not places and not label_places:
3534
+ return
3535
+
3536
+ def remove():
3537
+ for analysis, scan, _index in places:
3538
+ if analysis in scan.analysis_objects:
3539
+ scan.analysis_objects.remove(analysis)
3540
+ for label, _index in label_places:
3541
+ if label in self._shelf(label):
3542
+ self._shelf(label).remove(label)
3543
+
3544
+ def restore():
3545
+ for analysis, scan, index in places:
3546
+ if analysis not in scan.analysis_objects:
3547
+ scan.analysis_objects.insert(
3548
+ min(index, len(scan.analysis_objects)), analysis)
3549
+ for label, index in sorted(label_places, key=lambda p: p[1]):
3550
+ shelf = self._shelf(label)
3551
+ if label not in shelf:
3552
+ shelf.insert(min(index, len(shelf)), label)
3553
+
3554
+ count = len(places) + len(label_places)
3555
+ self.undo.push(undo.CallCommand(
3556
+ remove, restore, "remove {} object(s)".format(count)))
3557
+ self.refresh()
3558
+
3559
+ def _shelf(self, obj):
3560
+ """The document's list an added object lives in."""
3561
+ if isinstance(obj, model.ImageArtist):
3562
+ return self.doc.images
3563
+ if isinstance(obj, model.MoleculeArtist):
3564
+ return self.doc.structures
3565
+ return self.doc.labels
3566
+
3567
+ # ------------------------------------------------------------- images
3568
+ IMAGE_TYPES = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff",
3569
+ ".webp")
3570
+
3571
+ def add_image(self, picture, at=None):
3572
+ """Put a picture on the figure, undoably, where the pointer is (or
3573
+ `at`, figure units; the middle without either), at its own size up
3574
+ to 40 % of the plot's width."""
3575
+ from PySide6.QtCore import QBuffer, QIODevice
3576
+ if picture is None or picture.isNull():
3577
+ self.note.setText("No picture to put on the figure")
3578
+ return None
3579
+ self.ensure_figure()
3580
+ buffer = QBuffer()
3581
+ buffer.open(QIODevice.WriteOnly)
3582
+ picture.save(buffer, "PNG")
3583
+ png = bytes(buffer.data().toBase64()).decode("ascii")
3584
+ rect = self.plot.plot_rect()
3585
+ where = at or self.plot._cursor
3586
+ x = y = 0.5
3587
+ if where is not None:
3588
+ x = (where.x() - rect.left()) / max(1.0, rect.width())
3589
+ y = (where.y() - rect.top()) / max(1.0, rect.height())
3590
+ width = min(float(picture.width()), 0.4 * rect.width())
3591
+ image = model.ImageArtist(self.doc._next_id(), png,
3592
+ min(0.98, max(0.02, x)),
3593
+ min(0.98, max(0.02, y)), width)
3594
+ doc = self.doc
3595
+
3596
+ def put():
3597
+ if image not in doc.images:
3598
+ doc.images.append(image)
3599
+
3600
+ def take():
3601
+ if image in doc.images:
3602
+ doc.images.remove(image)
3603
+
3604
+ self.undo.push(undo.CallCommand(put, take, "add image"))
3605
+ doc.select_only([image])
3606
+ self.plot.keep_inside(image)
3607
+ self.refresh()
3608
+ self.note.setText("An image: S scales it, R rotates it")
3609
+ return image
3610
+
3611
+ def paste(self, as_text=False):
3612
+ """Ctrl+V: what the clipboard holds, as what it is - a picture (or
3613
+ an image file copied in the file browser) as a picture, a SMILES as
3614
+ a skeletal structure, any other text as a label.
3615
+ Ctrl+Shift+V (`as_text`) takes text as a label even when it reads
3616
+ as a SMILES: "CO" may well mean carbon monoxide's letters."""
3617
+ from PySide6.QtGui import QImage as _Image
3618
+ from ..core import chem
3619
+ clipboard = QApplication.clipboard()
3620
+ mime = clipboard.mimeData()
3621
+ if (not as_text and mime is not None
3622
+ and mime.hasFormat(self.LABELS_MIME)):
3623
+ made = self.paste_labels(bytes(mime.data(self.LABELS_MIME)))
3624
+ if made:
3625
+ return made
3626
+ if not as_text and mime is not None and mime.hasImage():
3627
+ return self.add_image(clipboard.image())
3628
+ if not as_text and mime is not None and mime.hasUrls():
3629
+ for url in mime.urls():
3630
+ path = url.toLocalFile()
3631
+ if path.lower().endswith(self.IMAGE_TYPES):
3632
+ return self.add_image(_Image(path))
3633
+ text = clipboard.text() if mime is not None else ""
3634
+ text = (text or "").replace("\r\n", "\n").strip("\n")
3635
+ if not text.strip():
3636
+ self.note.setText("Nothing to paste")
3637
+ return None
3638
+ if not as_text and chem.looks_like_smiles(text):
3639
+ made = self.add_molecule(text.strip())
3640
+ if made is not None:
3641
+ return made
3642
+ if (not as_text and not chem.available()
3643
+ and chem.plausible_smiles(text)):
3644
+ # Said, not pasted as words: the structure is what was meant.
3645
+ self.missing_rdkit()
3646
+ return None
3647
+ self.ensure_figure()
3648
+ return self.add_label(text)
3649
+
3650
+ # --------------------------------------------- copying labels about
3651
+ #: Labels on the clipboard: their look and their places, as JSON.
3652
+ LABELS_MIME = "application/x-{}-labels".format(branding.EXE_NAME)
3653
+
3654
+ def copyable(self):
3655
+ """The selected labels a Ctrl+C copies (marker lines are not)."""
3656
+ return [o for o in self.doc.selected()
3657
+ if isinstance(o, model.TextLabel) and o.vline is None]
3658
+
3659
+ def copy_selected(self):
3660
+ """Ctrl+C: the selected labels onto the clipboard - their text for
3661
+ any other program, and everything about them for pasting here:
3662
+ look, and where each is drawn
3663
+ relative to the first. Pasted they are FREE labels; one that
3664
+ should belong to a curve is then given to it (Ctrl+P, or dropped
3665
+ on it in the outliner)."""
3666
+ import json
3667
+ from PySide6.QtCore import QMimeData
3668
+ labels = self.copyable()
3669
+ if not labels:
3670
+ return 0
3671
+ plot = self.plot
3672
+ rect = plot.plot_rect()
3673
+ first = plot.artist_point(labels[0], rect)
3674
+ entries = []
3675
+ for label in labels:
3676
+ px, py = plot.artist_point(label, rect)
3677
+ box = plot.artist_box(label, rect)
3678
+ tip = plot.leader_tip(label, rect) if label.leader else None
3679
+ entries.append({
3680
+ "text": label.text, "size": label.size, "bold": label.bold,
3681
+ "colour": label.colour, "anchor": label.anchor,
3682
+ "rotation": label.rotation, "flush": label.flush,
3683
+ "leader_from": label.leader_from,
3684
+ "leader_colour": label.leader_colour,
3685
+ # the box's middle, and a note's tip, from the first label
3686
+ "at": [box.center().x() - first[0],
3687
+ box.center().y() - first[1]],
3688
+ "tip": (None if tip is None else
3689
+ [tip.x() - first[0], tip.y() - first[1]]),
3690
+ "from": [px - first[0], py - first[1]]})
3691
+ mime = QMimeData()
3692
+ mime.setText("\n".join(label.text for label in labels))
3693
+ mime.setData(self.LABELS_MIME,
3694
+ json.dumps({"labels": entries,
3695
+ "first": [first[0], first[1]]}).encode(
3696
+ "utf-8"))
3697
+ QApplication.clipboard().setMimeData(mime)
3698
+ self.note.setText("Copied {} label(s)".format(len(labels)))
3699
+ return len(labels)
3700
+
3701
+ def paste_labels(self, data):
3702
+ """Labels copied here, pasted as free labels: at the pointer when
3703
+ it is on the plot, else a little down and right of where they were
3704
+ copied from. One undo step; the new labels are selected."""
3705
+ import json
3706
+ try:
3707
+ state = json.loads(bytes(data).decode("utf-8"))
3708
+ entries = list(state["labels"])
3709
+ first = state.get("first") or [0.0, 0.0]
3710
+ except (ValueError, KeyError, TypeError):
3711
+ return None
3712
+ if not entries:
3713
+ return None
3714
+ self.ensure_figure()
3715
+ plot = self.plot
3716
+ rect = plot.plot_rect()
3717
+ cursor = plot._cursor
3718
+ if cursor is not None and rect.contains(cursor):
3719
+ origin = (cursor.x(), cursor.y())
3720
+ else:
3721
+ origin = (float(first[0]) + 14.0, float(first[1]) + 14.0)
3722
+ made = []
3723
+ for entry in entries:
3724
+ label = model.TextLabel(self.doc._next_id(),
3725
+ str(entry.get("text", "")), 0.5, 0.5)
3726
+ label.size = entry.get("size")
3727
+ label.bold = bool(entry.get("bold", False))
3728
+ label.colour = entry.get("colour") or "auto"
3729
+ label.anchor = entry.get("anchor") or "center"
3730
+ label.rotation = float(entry.get("rotation") or 0.0)
3731
+ flush = entry.get("flush")
3732
+ label.flush = flush if flush in ("left", "right",
3733
+ "center") else None
3734
+ start = entry.get("leader_from", "auto")
3735
+ label.leader_from = start if start in model.ANCHORS else "auto"
3736
+ label.leader_colour = entry.get("leader_colour") or "auto"
3737
+ dx, dy = entry.get("from") or [0.0, 0.0]
3738
+ plot.set_artist_point(label, origin[0] + float(dx),
3739
+ origin[1] + float(dy), rect)
3740
+ tip = entry.get("tip")
3741
+ if tip is not None:
3742
+ label.leader = plot.leader_value(label, QPointF(
3743
+ origin[0] + float(tip[0]), origin[1] + float(tip[1])),
3744
+ rect)
3745
+ made.append(label)
3746
+
3747
+ def add():
3748
+ for label in made:
3749
+ if label not in self.doc.labels:
3750
+ self.doc.labels.append(label)
3751
+ self.doc.select_only(made)
3752
+
3753
+ def take():
3754
+ for label in made:
3755
+ self.doc.remove_label(label)
3756
+
3757
+ self.undo.push(undo.CallCommand(add, take, "paste labels"))
3758
+ self.refresh()
3759
+ self.note.setText("Pasted {} label(s)".format(len(made)))
3760
+ return made
3761
+
3762
+ def missing_rdkit(self):
3763
+ """The pop-up for a SMILES pasted without RDKit (a method of its
3764
+ own, so a test can stand in for the modal box)."""
3765
+ QMessageBox.information(
3766
+ self, "RDKit is not installed",
3767
+ "This looks like a SMILES. Drawing it as a structure needs "
3768
+ "RDKit, which is not installed.\n\nInstall it with\n\n"
3769
+ " pip install rdkit\n\nand start {} again. Ctrl+Shift+V "
3770
+ "pastes the text as a label instead.".format(branding.APP_NAME))
3771
+
3772
+ def add_molecule(self, smiles, at=None):
3773
+ """A skeletal structure from a SMILES, undoably, where the pointer
3774
+ is (or `at`). None when it cannot be laid out (no RDKit, or not a
3775
+ molecule)."""
3776
+ from ..core import chem
3777
+ drawing = chem.layout(smiles)
3778
+ if drawing is None:
3779
+ self.note.setText("Not a structure RDKit can draw" if
3780
+ chem.available() else
3781
+ "Drawing a SMILES needs RDKit (pip install "
3782
+ "rdkit)")
3783
+ return None
3784
+ self.ensure_figure()
3785
+ rect = self.plot.plot_rect()
3786
+ where = at or self.plot._cursor
3787
+ x = y = 0.5
3788
+ if where is not None:
3789
+ x = (where.x() - rect.left()) / max(1.0, rect.width())
3790
+ y = (where.y() - rect.top()) / max(1.0, rect.height())
3791
+ structure = model.MoleculeArtist(self.doc._next_id(), smiles, drawing,
3792
+ min(0.98, max(0.02, x)),
3793
+ min(0.98, max(0.02, y)))
3794
+ doc = self.doc
3795
+
3796
+ def put():
3797
+ if structure not in doc.structures:
3798
+ doc.structures.append(structure)
3799
+
3800
+ def take():
3801
+ if structure in doc.structures:
3802
+ doc.structures.remove(structure)
3803
+
3804
+ self.undo.push(undo.CallCommand(put, take, "add structure"))
3805
+ doc.select_only([structure])
3806
+ self.plot.keep_inside(structure)
3807
+ self.refresh()
3808
+ self.note.setText("A structure: double-click for its settings")
3809
+ return structure
3810
+
3811
+ #: Which size of each kind Ctrl+Up / Ctrl+Down changes.
3812
+ TEXT_SIZES = {"label": "size", "legend": "size", "arrow": "size",
3813
+ "offset_marker": "size", "analysis": "label_size",
3814
+ "axis": "label_size", "molecule": "label_size"}
3815
+
3816
+ def text_size_step(self, step):
3817
+ """Ctrl+Up / Ctrl+Down: every selected text one point bigger or
3818
+ smaller, from the size it is drawn at. One undo step."""
3819
+ changes = []
3820
+ for obj in self.doc.selected():
3821
+ attr = self.TEXT_SIZES.get(getattr(obj, "kind", ""))
3822
+ if attr is None:
3823
+ continue
3824
+ now = float(self.plot.style_of(obj, attr) or 10.0)
3825
+ changes.append((obj, attr, max(4.0, round(now + step, 1))))
3826
+ if not changes:
3827
+ self.note.setText("Select a text first: a label, an analysis, "
3828
+ "the legend...")
3829
+ return 0
3830
+ self.undo.set_props(changes, "text size")
3831
+ return len(changes)
3832
+
3833
+ def remove_selected_files(self):
3834
+ samples = []
3835
+ for scan in self.doc.selected_scans():
3836
+ if scan.sample not in samples:
3837
+ samples.append(scan.sample)
3838
+ scans = [s for sample in samples for s in sample.scans]
3839
+ self.remove_scans(scans, "remove {} file(s)".format(len(samples)))
3840
+
3841
+ def colour_selected(self):
3842
+ scans = self.doc.selected_scans()
3843
+ if not scans:
3844
+ return
3845
+ colour = colour_module.get_colour(QColor(scans[0].colour), self,
3846
+ "Colour for the selection")
3847
+ if not colour.isValid():
3848
+ return
3849
+ self.undo.set_props([(s, "colour", colour.name()) for s in scans],
3850
+ "colour")
3851
+
3852
+ def gradient_dialog(self, scans=None):
3853
+ """The shades dialog for `scans` (the selected ones), built and not
3854
+ shown, so a test can drive it (`colour_gradient` shows it)."""
3855
+ scans = list(scans or self.doc.selected_scans())
3856
+ if len(scans) < 2:
3857
+ return None
3858
+ return colour_module.GradientDialog(scans, self,
3859
+ on_change=self._live_change)
3860
+
3861
+ def colour_gradient(self):
3862
+ """F3: shades of one colour on the selected curves, live, one undo
3863
+ step when the dialog closes."""
3864
+ dialog = self.gradient_dialog()
3865
+ if dialog is None:
3866
+ return None
3867
+ dialog.finished.connect(
3868
+ lambda _result, d=dialog: self._gradient_done(d))
3869
+ self._dialogs = [d for d in getattr(self, "_dialogs", [])
3870
+ if d.isVisible()] + [dialog]
3871
+ dialog.show()
3872
+ dialog.raise_()
3873
+ return dialog
3874
+
3875
+ def _gradient_done(self, dialog):
3876
+ changes = dialog.changes()
3877
+ if changes:
3878
+ self.undo.set_props(changes, "colour gradient")
3879
+ self._live_change()
3880
+
3881
+ def ask_molar_mass(self, scans=None):
3882
+ scans = scans or self.doc.selected_scans()
3883
+ if not scans:
3884
+ return None
3885
+ samples = []
3886
+ for scan in scans:
3887
+ if scan.sample not in samples:
3888
+ samples.append(scan.sample)
3889
+ # The number, with the calculator beside it.
3890
+ from .dialogs import MolarMassPrompt
3891
+ prompt = MolarMassPrompt(samples, self)
3892
+ if not prompt.exec():
3893
+ return None
3894
+ value = prompt.value()
3895
+ made_of = samples[0].composition
3896
+ changes = [(s, "molar_mass", value) for s in samples]
3897
+ changes += [(s, "composition", made_of) for s in samples[1:]]
3898
+ self.undo.set_props(changes, "molar mass")
3899
+ return value
3900
+
3901
+ # ----------------------------------------------------------------- views
3902
+ def set_theme(self, name):
3903
+ """Switch the palette, and remember it on the document. The windows
3904
+ around the plot follow it (`ui/appearance.py`)."""
3905
+ if name in plot_module.THEMES:
3906
+ self.doc.theme = name
3907
+ # The page may still be of the other family (`drawing_theme`).
3908
+ plot_module.set_theme(self.drawing_theme(), accent=self.doc.theme)
3909
+ appearance.apply(self.doc.theme)
3910
+ self.plot.invalidate()
3911
+ self.outliner.refill() # its scan colours follow the theme
3912
+ self.note.setText("Theme: {}".format(self.doc.theme))
3913
+
3914
+ def about_text(self):
3915
+ """What Help > About says: the version, what it is running on, and
3916
+ where the pieces came from. Separate from showing it, so a test can
3917
+ read it without a modal box."""
3918
+ import platform
3919
+ import PySide6
3920
+ from PySide6.QtCore import qVersion
3921
+ from .. import __version__
3922
+ return (
3923
+ "<p><b>{name}</b> {version}<br>{what}</p>"
3924
+ "<p>TRIOS reader: {reader}<br>"
3925
+ "Python {python} &nbsp;/&nbsp; PySide6 {pyside} &nbsp;/&nbsp; "
3926
+ "Qt {qt}</p>"
3927
+ "<p>Your defaults: {prefs}</p>"
3928
+ "<p>{copyright}<br><a href=\"{repo}\">{repo}</a></p>").format(
3929
+ name=branding.APP_NAME, version=__version__,
3930
+ what=branding.DESCRIPTION,
3931
+ reader=loader.reader_origin(),
3932
+ python=platform.python_version(),
3933
+ pyside=PySide6.__version__, qt=qVersion(),
3934
+ prefs=style.preferences_path(),
3935
+ copyright=branding.COPYRIGHT, repo=branding.HOMEPAGE)
3936
+
3937
+ def show_about(self):
3938
+ QMessageBox.about(self, "About {}".format(branding.APP_NAME),
3939
+ self.about_text())
3940
+
3941
+ def set_axis(self, axis):
3942
+ self.doc.x_axis = axis
3943
+ for scan in self.doc.scans:
3944
+ scan._cache_key = None
3945
+ self.refresh(keep_view=False)
3946
+
3947
+ # ------------------------------------------------------- SDT and TGA
3948
+ def has_weight(self):
3949
+ """True when a scan on the figure recorded a weight."""
3950
+ return any(s.has_weight() for s in self.doc.scans)
3951
+
3952
+ def set_weight_unit(self, unit):
3953
+ """The mass axis in % of the sample mass or in mg, the mass scans'
3954
+ offsets carried across like the heat flow's (`Document.
3955
+ set_weight_unit`); the mass range is fitted again."""
3956
+ if unit not in model.WEIGHT_UNITS or unit == self.doc.weight_unit:
3957
+ return
3958
+ changes = self.doc.set_weight_unit(unit)
3959
+ for scan in self.doc.scans:
3960
+ scan._cache_key = None
3961
+ if changes:
3962
+ self.undo.set_props(changes, "mass unit")
3963
+ self.plot._view_y2 = None
3964
+ self.refresh()
3965
+
3966
+ def set_dtg_unit(self, unit):
3967
+ """The DTG in %/degC or %/min, its scans' offsets carried across by
3968
+ each segment's heating rate; the y axis is fitted again."""
3969
+ if unit not in dtg.UNITS or unit == self.doc.dtg_unit:
3970
+ return
3971
+ changes = self.doc.set_dtg_unit(unit)
3972
+ for scan in self.doc.scans:
3973
+ scan._cache_key = None
3974
+ if changes:
3975
+ self.undo.set_props(changes, "DTG unit")
3976
+ if self.doc.y_signal() == model.SIGNAL_DTG:
3977
+ self.plot._view_y = None
3978
+ self.refresh()
3979
+
3980
+ def toggle_signal(self, signal):
3981
+ """One kind of curve on or off for every segment that has a curve
3982
+ on the figure: off when any of that kind is on, else on - made where
3983
+ a segment has never shown it. One undo step."""
3984
+ doc = self.doc
3985
+ mine = [s for s in doc.scans if s.signal == signal]
3986
+ if any(s.visible for s in mine):
3987
+ with self._room_for_axes("hide " + signal):
3988
+ self.undo.set_props([(s, "visible", False) for s in mine
3989
+ if s.visible], "hide " + signal)
3990
+ return
3991
+ wanted = []
3992
+ for scan in list(doc.scans):
3993
+ if not scan.visible or scan.signal == signal:
3994
+ continue
3995
+ if (signal in (model.SIGNAL_MASS, model.SIGNAL_DTG)
3996
+ and not scan.has_weight()):
3997
+ continue
3998
+ if (scan.sample, scan.seg) not in wanted:
3999
+ wanted.append((scan.sample, scan.seg))
4000
+ if not wanted:
4001
+ return
4002
+ with self._room_for_axes("show " + signal):
4003
+ for sample, seg in wanted:
4004
+ self._toggle_segment(sample, seg, True, signal)
4005
+ self.refresh()
4006
+
4007
+ def set_unit(self, unit):
4008
+ changes = self.doc.set_unit(unit)
4009
+ for scan in self.doc.scans:
4010
+ scan._cache_key = None
4011
+ if changes:
4012
+ self.undo.set_props(changes, "unit")
4013
+ self.refresh(keep_view=False)
4014
+
4015
+ def flip_arrow(self):
4016
+ arrow = self.doc.arrow
4017
+ other = (units.EXO_UP if arrow.direction == units.EXO_DOWN
4018
+ else units.EXO_DOWN)
4019
+ self.undo.set_props([(arrow, "direction", other)], "flip the arrow")
4020
+
4021
+ def relabel_arrow(self):
4022
+ """Say the same thing the other way round: exo down becomes endo up.
4023
+
4024
+ The figure does not change - that is the point. Both labels exist;
4025
+ `units.orientation` is what keeps relabelling from flipping the
4026
+ data when it should not.
4027
+ """
4028
+ arrow = self.doc.arrow
4029
+ word = (units.WORD_ENDO if arrow.word == units.WORD_EXO
4030
+ else units.WORD_EXO)
4031
+ direction = (units.EXO_UP if arrow.direction == units.EXO_DOWN
4032
+ else units.EXO_DOWN)
4033
+ self.undo.set_props([(arrow, "word", word),
4034
+ (arrow, "direction", direction)], "relabel")
4035
+
4036
+ # ------------------------------------------------------- drag and drop
4037
+ def dragEnterEvent(self, event):
4038
+ if self._dropped(event) :
4039
+ event.acceptProposedAction()
4040
+ else:
4041
+ event.ignore()
4042
+
4043
+ def dragMoveEvent(self, event):
4044
+ self.dragEnterEvent(event)
4045
+
4046
+ def dropEvent(self, event):
4047
+ paths = self._dropped(event)
4048
+ if not paths:
4049
+ event.ignore()
4050
+ return
4051
+ event.acceptProposedAction()
4052
+ pictures = [p for p in paths if p.lower().endswith(self.IMAGE_TYPES)]
4053
+ if pictures:
4054
+ from PySide6.QtGui import QImage as _Image
4055
+ at = self.plot.to_figure(self.plot.mapFrom(
4056
+ self, event.position().toPoint()))
4057
+ for path in pictures:
4058
+ self.add_image(_Image(path), at=at)
4059
+ paths = [p for p in paths if p not in pictures]
4060
+ if not paths:
4061
+ return
4062
+ styles = [p for p in paths
4063
+ if p.lower().endswith(branding.PRESET_EXT)]
4064
+ for path in styles:
4065
+ self.install_preset(path)
4066
+ paths = [p for p in paths if p not in styles]
4067
+ if not paths:
4068
+ return
4069
+ sessions = [p for p in paths
4070
+ if p.lower().endswith(branding.SESSION_EXT)]
4071
+ if sessions:
4072
+ self.open_session(sessions[0])
4073
+ return
4074
+ self.open_files(paths)
4075
+
4076
+ @staticmethod
4077
+ def _dropped(event):
4078
+ mime = event.mimeData()
4079
+ if mime is None or not mime.hasUrls():
4080
+ return []
4081
+ out = []
4082
+ for url in mime.urls():
4083
+ path = url.toLocalFile()
4084
+ if not path:
4085
+ continue
4086
+ if (loader.looks_readable(path)
4087
+ or path.lower().endswith(branding.SESSION_EXT)
4088
+ or path.lower().endswith(branding.PRESET_EXT)
4089
+ or path.lower().endswith(MainWindow.IMAGE_TYPES)):
4090
+ out.append(path)
4091
+ return out
4092
+
4093
+
4094
+ def _exact_svg_size(path, width_mm, height_mm):
4095
+ """Write an SVG's physical size to the hundredth of a micrometre.
4096
+
4097
+ The generator takes a size in WHOLE pixels at its resolution, so it can
4098
+ only say "84.67 mm" to the nearest quarter millimetre; the root element's
4099
+ width and height are rewritten with the exact values, and the viewBox
4100
+ (the drawing's own units) is left as it is.
4101
+ """
4102
+ with open(path, "r", encoding="utf-8") as fh:
4103
+ text = fh.read()
4104
+ head = re.search(r"<svg\b[^>]*>", text)
4105
+ if head is None:
4106
+ return False
4107
+ tag = head.group(0)
4108
+ tag = re.sub(r'\swidth="[^"]*"',
4109
+ lambda _m: ' width="{:.4f}mm"'.format(width_mm), tag, count=1)
4110
+ tag = re.sub(r'\sheight="[^"]*"',
4111
+ lambda _m: ' height="{:.4f}mm"'.format(height_mm), tag,
4112
+ count=1)
4113
+ text = text[:head.start()] + tag + text[head.end():]
4114
+ with open(path, "w", encoding="utf-8", newline="\n") as fh:
4115
+ fh.write(text)
4116
+ return True
4117
+
4118
+
4119
+ def _same_pair(a, b, tolerance=1e-9):
4120
+ """True when two (low, high) pairs are the same range - the y range
4121
+ offered by M and not touched."""
4122
+ scale = max(1e-12, abs(b[1] - b[0]))
4123
+ return (abs(a[0] - b[0]) <= tolerance * scale * 1e6
4124
+ and abs(a[1] - b[1]) <= tolerance * scale * 1e6)
4125
+
4126
+ # Qt calls the handlers here by itself; an error in one is logged and
4127
+ # survived rather than the end of the program (`core/log.py`).
4128
+ from ..core import log as _log
4129
+ _log.guard_classes(globals(), __name__)