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/__init__.py +4 -0
- dscpanel/__main__.py +155 -0
- dscpanel/branding.py +142 -0
- dscpanel/core/__init__.py +0 -0
- dscpanel/core/arrange.py +143 -0
- dscpanel/core/chem.py +252 -0
- dscpanel/core/dtg.py +135 -0
- dscpanel/core/export.py +649 -0
- dscpanel/core/figure.py +171 -0
- dscpanel/core/labels.py +430 -0
- dscpanel/core/loader.py +202 -0
- dscpanel/core/log.py +147 -0
- dscpanel/core/measure.py +636 -0
- dscpanel/core/model.py +1993 -0
- dscpanel/core/molar.py +344 -0
- dscpanel/core/numbers.py +208 -0
- dscpanel/core/ops.py +161 -0
- dscpanel/core/presets.py +312 -0
- dscpanel/core/profile.py +15 -0
- dscpanel/core/session.py +667 -0
- dscpanel/core/shades.py +54 -0
- dscpanel/core/style.py +528 -0
- dscpanel/core/trios_analysis.py +636 -0
- dscpanel/core/trios_io.py +1311 -0
- dscpanel/core/undo.py +230 -0
- dscpanel/core/units.py +220 -0
- dscpanel/register.py +284 -0
- dscpanel/ui/__init__.py +0 -0
- dscpanel/ui/appearance.py +146 -0
- dscpanel/ui/colour.py +629 -0
- dscpanel/ui/dialogs.py +3639 -0
- dscpanel/ui/loading.py +95 -0
- dscpanel/ui/numbox.py +103 -0
- dscpanel/ui/outliner.py +818 -0
- dscpanel/ui/palette.py +193 -0
- dscpanel/ui/plot.py +8349 -0
- dscpanel/ui/settings.py +256 -0
- dscpanel/ui/window.py +4129 -0
- triplot-1.1.0.dist-info/METADATA +315 -0
- triplot-1.1.0.dist-info/RECORD +44 -0
- triplot-1.1.0.dist-info/WHEEL +5 -0
- triplot-1.1.0.dist-info/entry_points.txt +5 -0
- triplot-1.1.0.dist-info/licenses/LICENSE +22 -0
- triplot-1.1.0.dist-info/top_level.txt +1 -0
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} / PySide6 {pyside} / "
|
|
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__)
|