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/dialogs.py
ADDED
|
@@ -0,0 +1,3639 @@
|
|
|
1
|
+
"""Per-object settings, applied as they are touched.
|
|
2
|
+
|
|
3
|
+
Live-apply with a snapshot for Cancel, which is the right rule for anything
|
|
4
|
+
judged by eye: a dialog you have to close before you can see what it did
|
|
5
|
+
makes a knob unusable. Cancel puts back what was there when the dialog
|
|
6
|
+
opened, because by then the object has already been changed a dozen times.
|
|
7
|
+
|
|
8
|
+
`NumberBox` exists because on a German locale Qt's decimal separator is a
|
|
9
|
+
comma, so a typed "0.15" is not a number and the box quietly keeps its old
|
|
10
|
+
value. Both forms are taken and the typed text is left alone while it is
|
|
11
|
+
being typed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import QLocale, QPointF, Qt, Signal
|
|
15
|
+
from PySide6.QtGui import QColor, QFont, QValidator
|
|
16
|
+
from PySide6.QtWidgets import (QAbstractSpinBox, QCheckBox, QColorDialog,
|
|
17
|
+
QComboBox, QDialog, QDialogButtonBox,
|
|
18
|
+
QDoubleSpinBox, QFontComboBox, QFormLayout,
|
|
19
|
+
QHBoxLayout, QLabel, QLayout, QLineEdit,
|
|
20
|
+
QListWidget, QListWidgetItem, QPlainTextEdit,
|
|
21
|
+
QPushButton, QScrollArea, QSpinBox,
|
|
22
|
+
QVBoxLayout, QWidget)
|
|
23
|
+
|
|
24
|
+
#: How much of the screen's height a settings window may take before its
|
|
25
|
+
#: rows scroll (an integration's settings ran off a small screen). Small
|
|
26
|
+
#: ones never reach it.
|
|
27
|
+
SCREEN_SHARE = 0.85
|
|
28
|
+
|
|
29
|
+
import html
|
|
30
|
+
import os
|
|
31
|
+
|
|
32
|
+
from ..core import figure as figure_module
|
|
33
|
+
from ..core import labels
|
|
34
|
+
from ..core import measure
|
|
35
|
+
from ..core import molar
|
|
36
|
+
from ..core import model as units_module
|
|
37
|
+
from ..core import numbers
|
|
38
|
+
from ..core import style
|
|
39
|
+
from ..core import units
|
|
40
|
+
from .colour import MORE_COLOURS, PLOTTER_COLOURS
|
|
41
|
+
from .colour import get_colour as pick_colour
|
|
42
|
+
from .numbox import NumberBox, WholeBox
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RangeDialog(QDialog):
|
|
46
|
+
"""Two numbers, typed: MestReNova's `M` for the x range.
|
|
47
|
+
|
|
48
|
+
Built to be typed through without the mouse. The FIRST number is
|
|
49
|
+
selected when it opens, so typing replaces it; Tab goes to the second,
|
|
50
|
+
selected in turn; Enter takes both. A comma is a decimal point, as in
|
|
51
|
+
`NumberBox`. The two may come in either order, and the dialog does not
|
|
52
|
+
close on a pair that is not a range.
|
|
53
|
+
|
|
54
|
+
Built here and shown by the window (`MainWindow.ask_x_range`), so a
|
|
55
|
+
test can fill it without a modal loop.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, title, unit, low, high, parent=None, y=None,
|
|
59
|
+
y2=None):
|
|
60
|
+
QDialog.__init__(self, parent)
|
|
61
|
+
self.setWindowTitle(title)
|
|
62
|
+
row = QHBoxLayout()
|
|
63
|
+
self.low_edit = QLineEdit(_number_text(low), self)
|
|
64
|
+
self.high_edit = QLineEdit(_number_text(high), self)
|
|
65
|
+
for edit in (self.low_edit, self.high_edit):
|
|
66
|
+
edit.setAlignment(Qt.AlignRight)
|
|
67
|
+
edit.setMinimumWidth(80)
|
|
68
|
+
edit.textEdited.connect(self._clear_mark)
|
|
69
|
+
if y is not None:
|
|
70
|
+
row.addWidget(QLabel("x", self))
|
|
71
|
+
row.addWidget(self.low_edit)
|
|
72
|
+
row.addWidget(QLabel("to", self))
|
|
73
|
+
row.addWidget(self.high_edit)
|
|
74
|
+
if unit:
|
|
75
|
+
row.addWidget(QLabel(unit, self))
|
|
76
|
+
# The y range, AFTER the x one: Tab reaches it only when wanted,
|
|
77
|
+
# and Enter after the x pair leaves it as it is.
|
|
78
|
+
self.y_low_edit = self.y_high_edit = None
|
|
79
|
+
y_row = None
|
|
80
|
+
if y is not None:
|
|
81
|
+
y_unit, y_low, y_high = y
|
|
82
|
+
y_row = QHBoxLayout()
|
|
83
|
+
self.y_low_edit = QLineEdit(_number_text(y_low), self)
|
|
84
|
+
self.y_high_edit = QLineEdit(_number_text(y_high), self)
|
|
85
|
+
y_row.addWidget(QLabel("y", self))
|
|
86
|
+
for edit in (self.y_low_edit, self.y_high_edit):
|
|
87
|
+
edit.setAlignment(Qt.AlignRight)
|
|
88
|
+
edit.setMinimumWidth(80)
|
|
89
|
+
edit.textEdited.connect(self._clear_mark)
|
|
90
|
+
y_row.addWidget(self.y_low_edit)
|
|
91
|
+
y_row.addWidget(QLabel("to", self))
|
|
92
|
+
y_row.addWidget(self.y_high_edit)
|
|
93
|
+
if y_unit:
|
|
94
|
+
y_row.addWidget(QLabel(y_unit, self))
|
|
95
|
+
# The weight axis of an SDT run, last.
|
|
96
|
+
self.y2_low_edit = self.y2_high_edit = None
|
|
97
|
+
y2_row = None
|
|
98
|
+
if y2 is not None:
|
|
99
|
+
y2_unit, y2_low, y2_high = y2
|
|
100
|
+
y2_row = QHBoxLayout()
|
|
101
|
+
self.y2_low_edit = QLineEdit(_number_text(y2_low), self)
|
|
102
|
+
self.y2_high_edit = QLineEdit(_number_text(y2_high), self)
|
|
103
|
+
y2_row.addWidget(QLabel("mass", self))
|
|
104
|
+
for edit in (self.y2_low_edit, self.y2_high_edit):
|
|
105
|
+
edit.setAlignment(Qt.AlignRight)
|
|
106
|
+
edit.setMinimumWidth(80)
|
|
107
|
+
edit.textEdited.connect(self._clear_mark)
|
|
108
|
+
y2_row.addWidget(self.y2_low_edit)
|
|
109
|
+
y2_row.addWidget(QLabel("to", self))
|
|
110
|
+
y2_row.addWidget(self.y2_high_edit)
|
|
111
|
+
if y2_unit:
|
|
112
|
+
y2_row.addWidget(QLabel(y2_unit, self))
|
|
113
|
+
buttons = QDialogButtonBox(
|
|
114
|
+
QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
|
|
115
|
+
buttons.accepted.connect(self.accept)
|
|
116
|
+
buttons.rejected.connect(self.reject)
|
|
117
|
+
# Enter takes the pair from either box, never a button's own idea.
|
|
118
|
+
buttons.button(QDialogButtonBox.Ok).setDefault(True)
|
|
119
|
+
layout = QVBoxLayout(self)
|
|
120
|
+
layout.addLayout(row)
|
|
121
|
+
if y_row is not None:
|
|
122
|
+
layout.addLayout(y_row)
|
|
123
|
+
if y2_row is not None:
|
|
124
|
+
layout.addLayout(y2_row)
|
|
125
|
+
layout.addWidget(buttons)
|
|
126
|
+
self.setTabOrder(self.low_edit, self.high_edit)
|
|
127
|
+
if y_row is not None:
|
|
128
|
+
self.setTabOrder(self.high_edit, self.y_low_edit)
|
|
129
|
+
self.setTabOrder(self.y_low_edit, self.y_high_edit)
|
|
130
|
+
if y2_row is not None:
|
|
131
|
+
self.setTabOrder(self.y_high_edit or self.high_edit,
|
|
132
|
+
self.y2_low_edit)
|
|
133
|
+
self.setTabOrder(self.y2_low_edit, self.y2_high_edit)
|
|
134
|
+
self.low_edit.setFocus(Qt.OtherFocusReason)
|
|
135
|
+
self.low_edit.selectAll()
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _read(edit):
|
|
139
|
+
return numbers.evaluate(edit.text())
|
|
140
|
+
|
|
141
|
+
def values(self):
|
|
142
|
+
"""`(low, high)` in increasing order, or None if it is not a range."""
|
|
143
|
+
return self._pair(self.low_edit, self.high_edit)
|
|
144
|
+
|
|
145
|
+
def y_values(self):
|
|
146
|
+
"""The y pair like `values`, or None (no y row, or not a range)."""
|
|
147
|
+
if self.y_low_edit is None:
|
|
148
|
+
return None
|
|
149
|
+
return self._pair(self.y_low_edit, self.y_high_edit)
|
|
150
|
+
|
|
151
|
+
def y2_values(self):
|
|
152
|
+
"""The mass axis's pair like `values`, or None."""
|
|
153
|
+
if self.y2_low_edit is None:
|
|
154
|
+
return None
|
|
155
|
+
return self._pair(self.y2_low_edit, self.y2_high_edit)
|
|
156
|
+
|
|
157
|
+
def _pair(self, first, second):
|
|
158
|
+
low, high = self._read(first), self._read(second)
|
|
159
|
+
if low is None or high is None or low == high:
|
|
160
|
+
return None
|
|
161
|
+
return (min(low, high), max(low, high))
|
|
162
|
+
|
|
163
|
+
def _edits(self):
|
|
164
|
+
return [e for e in (self.low_edit, self.high_edit, self.y_low_edit,
|
|
165
|
+
self.y_high_edit, self.y2_low_edit,
|
|
166
|
+
self.y2_high_edit) if e is not None]
|
|
167
|
+
|
|
168
|
+
def _clear_mark(self, _text=""):
|
|
169
|
+
for edit in self._edits():
|
|
170
|
+
edit.setStyleSheet("")
|
|
171
|
+
|
|
172
|
+
def accept(self):
|
|
173
|
+
pairs = [((self.low_edit, self.high_edit), self.values())]
|
|
174
|
+
if self.y_low_edit is not None:
|
|
175
|
+
pairs.append(((self.y_low_edit, self.y_high_edit),
|
|
176
|
+
self.y_values()))
|
|
177
|
+
if self.y2_low_edit is not None:
|
|
178
|
+
pairs.append(((self.y2_low_edit, self.y2_high_edit),
|
|
179
|
+
self.y2_values()))
|
|
180
|
+
wrong = False
|
|
181
|
+
for edits, value in pairs:
|
|
182
|
+
if value is None:
|
|
183
|
+
wrong = True
|
|
184
|
+
for edit in edits:
|
|
185
|
+
edit.setStyleSheet("border: 1px solid #d04040;")
|
|
186
|
+
if not wrong:
|
|
187
|
+
QDialog.accept(self)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class PageSizeDialog(QDialog):
|
|
191
|
+
"""The figure's size in numbers: a double-click on a page handle.
|
|
192
|
+
|
|
193
|
+
Width and height, in cm or inches. With "Keep the aspect ratio" on (the
|
|
194
|
+
default) typing one fills in the other at the page's present
|
|
195
|
+
proportions, so a figure is made exactly 8.5 cm wide without its shape
|
|
196
|
+
changing; off, the two are free and the ratio follows them. Typed through
|
|
197
|
+
like `RangeDialog`: the width is selected on opening, Tab goes to the
|
|
198
|
+
height, Enter takes them. A comma is a decimal point.
|
|
199
|
+
|
|
200
|
+
`least` is the smallest width and height, in cm, the margins leave room
|
|
201
|
+
for: a smaller page is refused (marked red) rather than made.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(self, width, height, unit, parent=None, least=(0.0, 0.0)):
|
|
205
|
+
QDialog.__init__(self, parent)
|
|
206
|
+
self.setWindowTitle("Figure size")
|
|
207
|
+
self._unit = unit
|
|
208
|
+
self._ratio = float(width) / max(1e-9, float(height))
|
|
209
|
+
self._least_cm = (float(least[0]), float(least[1]))
|
|
210
|
+
row = QHBoxLayout()
|
|
211
|
+
self.width_edit = QLineEdit(_length_text(width), self)
|
|
212
|
+
self.height_edit = QLineEdit(_length_text(height), self)
|
|
213
|
+
for edit in (self.width_edit, self.height_edit):
|
|
214
|
+
edit.setAlignment(Qt.AlignRight)
|
|
215
|
+
edit.setMinimumWidth(70)
|
|
216
|
+
self.unit_box = QComboBox(self)
|
|
217
|
+
for choice in ("cm", "in"):
|
|
218
|
+
self.unit_box.addItem(choice, choice)
|
|
219
|
+
self.unit_box.setCurrentIndex(0 if unit == "cm" else 1)
|
|
220
|
+
row.addWidget(QLabel("Width", self))
|
|
221
|
+
row.addWidget(self.width_edit)
|
|
222
|
+
row.addWidget(QLabel("x Height", self))
|
|
223
|
+
row.addWidget(self.height_edit)
|
|
224
|
+
row.addWidget(self.unit_box)
|
|
225
|
+
self.keep = QCheckBox("Keep the aspect ratio", self)
|
|
226
|
+
self.keep.setChecked(True)
|
|
227
|
+
self.keep.setToolTip("Typing one of the two fills in the other at "
|
|
228
|
+
"the page's present proportions.")
|
|
229
|
+
self.ratio_note = QLabel(self)
|
|
230
|
+
self.ratio_note.setStyleSheet("color: #9a9a9a;")
|
|
231
|
+
buttons = QDialogButtonBox(
|
|
232
|
+
QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
|
|
233
|
+
buttons.accepted.connect(self.accept)
|
|
234
|
+
buttons.rejected.connect(self.reject)
|
|
235
|
+
buttons.button(QDialogButtonBox.Ok).setDefault(True)
|
|
236
|
+
layout = QVBoxLayout(self)
|
|
237
|
+
layout.addLayout(row)
|
|
238
|
+
layout.addWidget(self.keep)
|
|
239
|
+
layout.addWidget(self.ratio_note)
|
|
240
|
+
layout.addWidget(buttons)
|
|
241
|
+
self.width_edit.textEdited.connect(lambda _t: self._typed("width"))
|
|
242
|
+
self.height_edit.textEdited.connect(lambda _t: self._typed("height"))
|
|
243
|
+
self.unit_box.currentIndexChanged.connect(self._unit_changed)
|
|
244
|
+
self.setTabOrder(self.width_edit, self.height_edit)
|
|
245
|
+
self._show_ratio()
|
|
246
|
+
self.width_edit.setFocus(Qt.OtherFocusReason)
|
|
247
|
+
self.width_edit.selectAll()
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _read(edit):
|
|
251
|
+
value = numbers.evaluate(edit.text())
|
|
252
|
+
return value if value is not None and 0 < value < 1e4 else None
|
|
253
|
+
|
|
254
|
+
def unit(self):
|
|
255
|
+
return self.unit_box.currentData()
|
|
256
|
+
|
|
257
|
+
def values(self):
|
|
258
|
+
"""`(width, height, unit)`, or None while either is not a length."""
|
|
259
|
+
width, height = self._read(self.width_edit), self._read(
|
|
260
|
+
self.height_edit)
|
|
261
|
+
if width is None or height is None:
|
|
262
|
+
return None
|
|
263
|
+
return width, height, self.unit()
|
|
264
|
+
|
|
265
|
+
def _typed(self, which):
|
|
266
|
+
"""One of the two was typed in: with the ratio kept, the other
|
|
267
|
+
follows; without, the ratio does."""
|
|
268
|
+
for edit in (self.width_edit, self.height_edit):
|
|
269
|
+
edit.setStyleSheet("")
|
|
270
|
+
source = self.width_edit if which == "width" else self.height_edit
|
|
271
|
+
other = self.height_edit if which == "width" else self.width_edit
|
|
272
|
+
value = self._read(source)
|
|
273
|
+
if value is None:
|
|
274
|
+
return
|
|
275
|
+
if self.keep.isChecked():
|
|
276
|
+
other.setText(_length_text(value / self._ratio if which == "width"
|
|
277
|
+
else value * self._ratio))
|
|
278
|
+
else:
|
|
279
|
+
width, height = (self._read(self.width_edit),
|
|
280
|
+
self._read(self.height_edit))
|
|
281
|
+
if width and height:
|
|
282
|
+
self._ratio = width / height
|
|
283
|
+
self._show_ratio()
|
|
284
|
+
|
|
285
|
+
def _unit_changed(self, _index=0):
|
|
286
|
+
"""Both numbers converted, so switching cm and in changes nothing."""
|
|
287
|
+
new = self.unit()
|
|
288
|
+
if new == self._unit:
|
|
289
|
+
return
|
|
290
|
+
factor = 2.54 if new == "cm" else 1.0 / 2.54
|
|
291
|
+
for edit in (self.width_edit, self.height_edit):
|
|
292
|
+
value = self._read(edit)
|
|
293
|
+
if value is not None:
|
|
294
|
+
edit.setText(_length_text(value * factor))
|
|
295
|
+
self._unit = new
|
|
296
|
+
|
|
297
|
+
def _show_ratio(self):
|
|
298
|
+
self.ratio_note.setText("Aspect ratio {:.4g} : 1".format(self._ratio))
|
|
299
|
+
|
|
300
|
+
def accept(self):
|
|
301
|
+
values = self.values()
|
|
302
|
+
wrong = []
|
|
303
|
+
if values is None:
|
|
304
|
+
wrong = [e for e in (self.width_edit, self.height_edit)
|
|
305
|
+
if self._read(e) is None]
|
|
306
|
+
else:
|
|
307
|
+
per_cm = 1.0 if values[2] == "cm" else 2.54
|
|
308
|
+
if values[0] * per_cm <= self._least_cm[0]:
|
|
309
|
+
wrong.append(self.width_edit)
|
|
310
|
+
if values[1] * per_cm <= self._least_cm[1]:
|
|
311
|
+
wrong.append(self.height_edit)
|
|
312
|
+
for edit in wrong:
|
|
313
|
+
edit.setStyleSheet("border: 1px solid #d04040;")
|
|
314
|
+
if wrong:
|
|
315
|
+
wrong[0].setToolTip("Smaller than the margins leave room for.")
|
|
316
|
+
return
|
|
317
|
+
QDialog.accept(self)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class PresetSaveDialog(QDialog):
|
|
321
|
+
"""Save the figure's look as a style preset: a name, and whether the
|
|
322
|
+
size and margins go with it (`core/presets.py`)."""
|
|
323
|
+
|
|
324
|
+
def __init__(self, parent=None, name="", with_layout=True, taken=()):
|
|
325
|
+
QDialog.__init__(self, parent)
|
|
326
|
+
self.setWindowTitle("Save a style preset")
|
|
327
|
+
self._taken = set(n.lower() for n in taken)
|
|
328
|
+
self.name_edit = QLineEdit(name, self)
|
|
329
|
+
self.name_edit.setPlaceholderText("thesis, poster, ACS column...")
|
|
330
|
+
self.with_layout = QCheckBox("With the figure's size and margins",
|
|
331
|
+
self)
|
|
332
|
+
self.with_layout.setChecked(bool(with_layout))
|
|
333
|
+
self.with_layout.setToolTip(
|
|
334
|
+
"Then every figure given this preset has the same axes box.")
|
|
335
|
+
self.replaces = QLabel("", self)
|
|
336
|
+
self.replaces.setStyleSheet("color: #d0a040;")
|
|
337
|
+
buttons = QDialogButtonBox(
|
|
338
|
+
QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
|
|
339
|
+
buttons.accepted.connect(self.accept)
|
|
340
|
+
buttons.rejected.connect(self.reject)
|
|
341
|
+
form = QFormLayout()
|
|
342
|
+
form.addRow("Name", self.name_edit)
|
|
343
|
+
form.addRow("", self.with_layout)
|
|
344
|
+
layout = QVBoxLayout(self)
|
|
345
|
+
layout.addLayout(form)
|
|
346
|
+
layout.addWidget(self.replaces)
|
|
347
|
+
layout.addWidget(buttons)
|
|
348
|
+
self.name_edit.textChanged.connect(self._name_changed)
|
|
349
|
+
self._name_changed()
|
|
350
|
+
self.name_edit.setFocus(Qt.OtherFocusReason)
|
|
351
|
+
self.name_edit.selectAll()
|
|
352
|
+
|
|
353
|
+
def name(self):
|
|
354
|
+
return self.name_edit.text().strip()
|
|
355
|
+
|
|
356
|
+
def _name_changed(self, _text=""):
|
|
357
|
+
self.replaces.setText("Replaces the preset of that name."
|
|
358
|
+
if self.name().lower() in self._taken else "")
|
|
359
|
+
|
|
360
|
+
def accept(self):
|
|
361
|
+
if not self.name():
|
|
362
|
+
self.name_edit.setStyleSheet("border: 1px solid #d04040;")
|
|
363
|
+
return
|
|
364
|
+
QDialog.accept(self)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _length_text(value):
|
|
368
|
+
"""A length offered for editing: to the hundredth of a millimetre."""
|
|
369
|
+
return "{:.4g}".format(float(value)) if float(value) >= 100 else \
|
|
370
|
+
"{:.3f}".format(float(value)).rstrip("0").rstrip(".")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _number_text(value):
|
|
374
|
+
"""A limit as it should be offered for editing: short, and exact enough."""
|
|
375
|
+
return "{:.6g}".format(float(value))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def readable(root):
|
|
379
|
+
"""Every label in `root` can be selected and copied, and every tooltip
|
|
380
|
+
wraps.
|
|
381
|
+
|
|
382
|
+
Qt draws a plain-text tooltip on ONE line, however long, which is how
|
|
383
|
+
some of them ran across the whole screen; a rich-text one wraps at a
|
|
384
|
+
readable width. Labels become mouse-selectable only (not by keyboard,
|
|
385
|
+
so Tab still goes from field to field).
|
|
386
|
+
"""
|
|
387
|
+
for label in root.findChildren(QLabel):
|
|
388
|
+
label.setTextInteractionFlags(label.textInteractionFlags()
|
|
389
|
+
| Qt.TextSelectableByMouse)
|
|
390
|
+
for widget in [root] + root.findChildren(QWidget):
|
|
391
|
+
text = widget.toolTip()
|
|
392
|
+
if text and not text.startswith("<qt>"):
|
|
393
|
+
widget.setToolTip("<qt>{}</qt>".format(html.escape(text)))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def markup_html(text):
|
|
397
|
+
"""Figure markup (`*T*_{on}`, `\\Delta`) as rich text, for a preview."""
|
|
398
|
+
from .plot import markup_runs
|
|
399
|
+
out = []
|
|
400
|
+
for run, italic, subscript in markup_runs(text):
|
|
401
|
+
piece = html.escape(run)
|
|
402
|
+
if italic:
|
|
403
|
+
piece = "<i>{}</i>".format(piece)
|
|
404
|
+
if subscript:
|
|
405
|
+
piece = "<sub>{}</sub>".format(piece)
|
|
406
|
+
out.append(piece)
|
|
407
|
+
return "".join(out)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _plot_of(widget):
|
|
411
|
+
"""The plot a dialog's object is drawn on, or None."""
|
|
412
|
+
window = _window_of(widget)
|
|
413
|
+
return getattr(window, "plot", None) if window is not None else None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def arrow_offset_rows(dialog, form):
|
|
417
|
+
""""Arrow Offset" and "Shift by": the distance from the curve to a
|
|
418
|
+
label on an arrow, typed absolutely or moved relatively. The same rows
|
|
419
|
+
for an analysis and for a y-offset marker. Positive is ABOVE the curve,
|
|
420
|
+
as up is more on the y axis; in figure units (px)."""
|
|
421
|
+
dialog.arrow_offset = NumberBox()
|
|
422
|
+
dialog.arrow_shift = NumberBox()
|
|
423
|
+
for box in (dialog.arrow_offset, dialog.arrow_shift):
|
|
424
|
+
box.setDecimals(1)
|
|
425
|
+
box.setRange(-2000.0, 2000.0)
|
|
426
|
+
box.setSingleStep(2.0)
|
|
427
|
+
box.setSuffix(" px")
|
|
428
|
+
dialog.arrow_default = QCheckBox("Default")
|
|
429
|
+
row = QWidget(dialog)
|
|
430
|
+
line = QHBoxLayout(row)
|
|
431
|
+
line.setContentsMargins(0, 0, 0, 0)
|
|
432
|
+
line.addWidget(dialog.arrow_offset, 1)
|
|
433
|
+
line.addWidget(dialog.arrow_default, 0)
|
|
434
|
+
dialog.arrow_offset.setToolTip("Distance from the curve to the label; "
|
|
435
|
+
"positive is above.")
|
|
436
|
+
dialog.arrow_default.setToolTip("The automatic distance.")
|
|
437
|
+
dialog.arrow_shift.setToolTip("Adds to the arrow offset (Enter, or the "
|
|
438
|
+
"arrows). A group moves together.")
|
|
439
|
+
form.addRow("Arrow Offset", row)
|
|
440
|
+
form.addRow("Shift by", dialog.arrow_shift)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _window_of(widget):
|
|
444
|
+
"""The main window a dialog belongs to (for what only it can do: an
|
|
445
|
+
undo step that recomputes an analysis), or None."""
|
|
446
|
+
while widget is not None:
|
|
447
|
+
if hasattr(widget, "retype_interval"):
|
|
448
|
+
return widget
|
|
449
|
+
widget = widget.parentWidget()
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def enter_stays(dialog, ev):
|
|
454
|
+
"""Enter in a field COMMITS it and keeps the window open.
|
|
455
|
+
|
|
456
|
+
These windows apply as they are touched, and their values are tuned
|
|
457
|
+
against each other - the arrow's head against its tail, a size against
|
|
458
|
+
a distance. Enter closing the window after every value meant reopening
|
|
459
|
+
it for the next one. A spin box has already taken its value when the
|
|
460
|
+
key reaches the window; this only stops the window
|
|
461
|
+
from treating the same key as its OK button, and selects the field's
|
|
462
|
+
text so the next number can be typed straight over it. A focused
|
|
463
|
+
button still takes Enter. Returns True when the key was handled.
|
|
464
|
+
"""
|
|
465
|
+
if ev.key() not in (Qt.Key_Return, Qt.Key_Enter):
|
|
466
|
+
return False
|
|
467
|
+
focus = dialog.focusWidget()
|
|
468
|
+
if isinstance(focus, QPushButton):
|
|
469
|
+
return False
|
|
470
|
+
if isinstance(focus, QAbstractSpinBox):
|
|
471
|
+
focus.interpretText()
|
|
472
|
+
focus.selectAll()
|
|
473
|
+
elif isinstance(focus, QLineEdit):
|
|
474
|
+
focus.selectAll()
|
|
475
|
+
ev.accept()
|
|
476
|
+
return True
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class StyleText(QWidget):
|
|
480
|
+
"""A number FORMAT (`%.1f`) that is chosen here or follows a default.
|
|
481
|
+
|
|
482
|
+
Empty follows: the field then shows what it would be as grey text. A
|
|
483
|
+
format that is not one (text around the number, two numbers) is marked
|
|
484
|
+
and not taken - see `core/numbers.py` for why a unit may not be part of
|
|
485
|
+
it. `value()` is None for "follow", as the object stores it.
|
|
486
|
+
"""
|
|
487
|
+
|
|
488
|
+
changed = Signal()
|
|
489
|
+
|
|
490
|
+
def __init__(self, own, inherited, parent=None, reset_text="Default",
|
|
491
|
+
describe=None):
|
|
492
|
+
QWidget.__init__(self, parent)
|
|
493
|
+
self._own = numbers.normalise(own)
|
|
494
|
+
self._inherited = inherited
|
|
495
|
+
self._describe = describe
|
|
496
|
+
row = QHBoxLayout(self)
|
|
497
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
498
|
+
self.edit = QLineEdit(self)
|
|
499
|
+
self.edit.setToolTip(
|
|
500
|
+
"Python format: %.1f, %.3g (significant figures). A unit "
|
|
501
|
+
"converts: %.0f \u00b0F.")
|
|
502
|
+
self.reset = QPushButton(reset_text, self)
|
|
503
|
+
row.addWidget(self.edit, 1)
|
|
504
|
+
row.addWidget(self.reset, 0)
|
|
505
|
+
self._show()
|
|
506
|
+
self.edit.textEdited.connect(self._typed)
|
|
507
|
+
self.reset.clicked.connect(lambda _c=False: self._follow())
|
|
508
|
+
|
|
509
|
+
def value(self):
|
|
510
|
+
return self._own
|
|
511
|
+
|
|
512
|
+
def set_value(self, value):
|
|
513
|
+
self._own = numbers.normalise(value)
|
|
514
|
+
self._show(force=True)
|
|
515
|
+
self.changed.emit()
|
|
516
|
+
|
|
517
|
+
def refresh(self):
|
|
518
|
+
self._show()
|
|
519
|
+
|
|
520
|
+
def _show(self, force=False):
|
|
521
|
+
inherited = self._inherited() if self._inherited else ""
|
|
522
|
+
shown = ((self._describe(inherited) if self._describe else inherited)
|
|
523
|
+
or "automatic")
|
|
524
|
+
self.edit.setPlaceholderText("{} (default)".format(shown))
|
|
525
|
+
# Not while it is being typed in: the text there is the user's.
|
|
526
|
+
if force or not self.edit.hasFocus():
|
|
527
|
+
self.edit.setText(self._own or "")
|
|
528
|
+
self.edit.setStyleSheet("")
|
|
529
|
+
self.reset.setEnabled(self._own is not None)
|
|
530
|
+
|
|
531
|
+
def _typed(self, text):
|
|
532
|
+
if not text.strip():
|
|
533
|
+
self.edit.setStyleSheet("")
|
|
534
|
+
self._follow()
|
|
535
|
+
return
|
|
536
|
+
spec = numbers.normalise(text)
|
|
537
|
+
if spec is None:
|
|
538
|
+
self.edit.setStyleSheet("border: 1px solid #d04040;")
|
|
539
|
+
return
|
|
540
|
+
self.edit.setStyleSheet("")
|
|
541
|
+
self._own = spec
|
|
542
|
+
self.reset.setEnabled(True)
|
|
543
|
+
self.changed.emit()
|
|
544
|
+
|
|
545
|
+
def _follow(self):
|
|
546
|
+
self._own = None
|
|
547
|
+
self._show(force=True)
|
|
548
|
+
self.changed.emit()
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class FontChoice(QWidget):
|
|
552
|
+
"""A font FAMILY chosen here, or following a default.
|
|
553
|
+
|
|
554
|
+
`value()` is the family, "" for the system's own, or None for "follow".
|
|
555
|
+
"""
|
|
556
|
+
|
|
557
|
+
changed = Signal()
|
|
558
|
+
|
|
559
|
+
def __init__(self, own, inherited, parent=None, reset_text="Default"):
|
|
560
|
+
QWidget.__init__(self, parent)
|
|
561
|
+
self._own = own
|
|
562
|
+
self._inherited = inherited
|
|
563
|
+
row = QHBoxLayout(self)
|
|
564
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
565
|
+
self.combo = QFontComboBox(self)
|
|
566
|
+
self.reset = QPushButton(reset_text, self)
|
|
567
|
+
row.addWidget(self.combo, 1)
|
|
568
|
+
row.addWidget(self.reset, 0)
|
|
569
|
+
self._show()
|
|
570
|
+
self.combo.currentFontChanged.connect(self._picked)
|
|
571
|
+
self.reset.clicked.connect(lambda _c=False: self._follow())
|
|
572
|
+
|
|
573
|
+
def value(self):
|
|
574
|
+
return self._own
|
|
575
|
+
|
|
576
|
+
def set_value(self, value):
|
|
577
|
+
self._own = value
|
|
578
|
+
self._show()
|
|
579
|
+
self.changed.emit()
|
|
580
|
+
|
|
581
|
+
def refresh(self):
|
|
582
|
+
self._show()
|
|
583
|
+
|
|
584
|
+
def _family_shown(self):
|
|
585
|
+
family = self._own if self._own is not None else (
|
|
586
|
+
self._inherited() if self._inherited else "")
|
|
587
|
+
return family or QFont().family()
|
|
588
|
+
|
|
589
|
+
def _show(self):
|
|
590
|
+
self.combo.blockSignals(True)
|
|
591
|
+
self.combo.setCurrentFont(QFont(self._family_shown()))
|
|
592
|
+
self.combo.blockSignals(False)
|
|
593
|
+
self.reset.setEnabled(self._own is not None)
|
|
594
|
+
self.combo.setToolTip(
|
|
595
|
+
"Chosen here" if self._own is not None
|
|
596
|
+
else "Following the default: {}".format(self._family_shown()))
|
|
597
|
+
|
|
598
|
+
def _picked(self, font):
|
|
599
|
+
self._own = font.family()
|
|
600
|
+
self._show()
|
|
601
|
+
self.changed.emit()
|
|
602
|
+
|
|
603
|
+
def _follow(self):
|
|
604
|
+
self._own = None
|
|
605
|
+
self._show()
|
|
606
|
+
self.changed.emit()
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class StyleNumber(QWidget):
|
|
610
|
+
"""A size that is either chosen HERE or taken from the house style.
|
|
611
|
+
|
|
612
|
+
It always shows the value that is DRAWN. While it follows the default the
|
|
613
|
+
box says "(default)" and the button is greyed; typing a number makes the
|
|
614
|
+
size this object's own, and "Default" hands it back to the house style
|
|
615
|
+
(`core/style.py`). `value()` is None for "follow", which is exactly what
|
|
616
|
+
the object stores.
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
changed = Signal()
|
|
620
|
+
|
|
621
|
+
def __init__(self, setting, own, inherited, parent=None,
|
|
622
|
+
reset_text="Default"):
|
|
623
|
+
QWidget.__init__(self, parent)
|
|
624
|
+
self.setting = setting
|
|
625
|
+
self._own = own
|
|
626
|
+
#: A callable, so the box follows a default that changes while it is
|
|
627
|
+
#: open - the settings page edits the defaults live.
|
|
628
|
+
self._inherited = inherited
|
|
629
|
+
row = QHBoxLayout(self)
|
|
630
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
631
|
+
self.box = NumberBox(self)
|
|
632
|
+
self.box.setDecimals(setting.decimals)
|
|
633
|
+
self.box.setRange(float(setting.low), float(setting.high))
|
|
634
|
+
self.box.setSingleStep(float(setting.step))
|
|
635
|
+
self.reset = QPushButton(reset_text, self)
|
|
636
|
+
self.reset.setToolTip("Use the house style (Edit > Settings).")
|
|
637
|
+
row.addWidget(self.box, 1)
|
|
638
|
+
row.addWidget(self.reset, 0)
|
|
639
|
+
self._show()
|
|
640
|
+
self.box.valueChanged.connect(self._typed)
|
|
641
|
+
self.reset.clicked.connect(lambda _c=False: self._follow())
|
|
642
|
+
|
|
643
|
+
def value(self):
|
|
644
|
+
return self._own
|
|
645
|
+
|
|
646
|
+
def refresh(self):
|
|
647
|
+
"""Show the inherited value again - the default under it changed."""
|
|
648
|
+
self._show()
|
|
649
|
+
|
|
650
|
+
def _show(self):
|
|
651
|
+
shown = self._own if self._own is not None else self._inherited()
|
|
652
|
+
self.box.blockSignals(True)
|
|
653
|
+
self.box.setSuffix(self.setting.suffix + (
|
|
654
|
+
"" if self._own is not None else " (default)"))
|
|
655
|
+
self.box.setValue(float(shown))
|
|
656
|
+
self.box.blockSignals(False)
|
|
657
|
+
self.reset.setEnabled(self._own is not None)
|
|
658
|
+
|
|
659
|
+
def _typed(self, value):
|
|
660
|
+
self._own = float(value)
|
|
661
|
+
self._show()
|
|
662
|
+
self.changed.emit()
|
|
663
|
+
|
|
664
|
+
def _follow(self):
|
|
665
|
+
self._own = None
|
|
666
|
+
self._show()
|
|
667
|
+
self.changed.emit()
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class StyleChoice(QWidget):
|
|
671
|
+
"""A choice (the label alignment) that may follow the house style.
|
|
672
|
+
|
|
673
|
+
The first entry is "default (...)", naming what that currently means, and
|
|
674
|
+
stands for None; the rest are the choices themselves.
|
|
675
|
+
"""
|
|
676
|
+
|
|
677
|
+
changed = Signal()
|
|
678
|
+
|
|
679
|
+
def __init__(self, choices, own, describe_default, parent=None,
|
|
680
|
+
titles=None, allow_default=True):
|
|
681
|
+
QWidget.__init__(self, parent)
|
|
682
|
+
self._describe = describe_default
|
|
683
|
+
self._allow_default = allow_default
|
|
684
|
+
titles = titles or {}
|
|
685
|
+
row = QHBoxLayout(self)
|
|
686
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
687
|
+
self.combo = QComboBox(self)
|
|
688
|
+
if allow_default:
|
|
689
|
+
self.combo.addItem("", None)
|
|
690
|
+
for choice in choices:
|
|
691
|
+
self.combo.addItem(titles.get(choice, choice), choice)
|
|
692
|
+
row.addWidget(self.combo, 1)
|
|
693
|
+
self.refresh()
|
|
694
|
+
index = self.combo.findData(own) if own is not None else 0
|
|
695
|
+
self.combo.setCurrentIndex(max(0, index))
|
|
696
|
+
self.combo.currentIndexChanged.connect(
|
|
697
|
+
lambda _i=0: self.changed.emit())
|
|
698
|
+
|
|
699
|
+
def value(self):
|
|
700
|
+
return self.combo.currentData()
|
|
701
|
+
|
|
702
|
+
def set_value(self, value):
|
|
703
|
+
index = self.combo.findData(value)
|
|
704
|
+
if index >= 0:
|
|
705
|
+
self.combo.setCurrentIndex(index)
|
|
706
|
+
|
|
707
|
+
def refresh(self):
|
|
708
|
+
if self._allow_default:
|
|
709
|
+
self.combo.setItemText(0, "default ({})".format(self._describe()))
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _document_of(widget):
|
|
713
|
+
"""The document a dialog is about, from whichever window it belongs to.
|
|
714
|
+
|
|
715
|
+
Needed for the figure's own style, which sits between an object and the
|
|
716
|
+
user's defaults. None outside a window, which skips that level.
|
|
717
|
+
"""
|
|
718
|
+
while widget is not None:
|
|
719
|
+
doc = getattr(widget, "doc", None)
|
|
720
|
+
if isinstance(doc, units_module.Document):
|
|
721
|
+
return doc
|
|
722
|
+
widget = widget.parentWidget()
|
|
723
|
+
return None
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _style_number(dialog, obj, attr, reset_text="Default"):
|
|
727
|
+
"""The size field for `obj.attr`, following the house style."""
|
|
728
|
+
setting = style.BY_KEY[style.key_for(obj, attr)]
|
|
729
|
+
return StyleNumber(setting, getattr(obj, attr),
|
|
730
|
+
lambda: style.inherited(dialog.doc, obj, attr),
|
|
731
|
+
parent=dialog, reset_text=reset_text)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _style_text(dialog, obj, attr, describe=None):
|
|
735
|
+
"""The number-format field for `obj.attr`, following the house style."""
|
|
736
|
+
return StyleText(getattr(obj, attr),
|
|
737
|
+
lambda: style.inherited(dialog.doc, obj, attr) or "",
|
|
738
|
+
parent=dialog, describe=describe)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
class ArtistTransform(QWidget):
|
|
742
|
+
"""The position block every artist gets: where it is, and from which point.
|
|
743
|
+
|
|
744
|
+
One widget rather than the same four rows copied into each artist's
|
|
745
|
+
dialog, because the position is a property of being an ARTIST - see
|
|
746
|
+
`core.model.Artist`. `space` decides what the two numbers mean: a
|
|
747
|
+
fraction of the plot, which keeps a caption in its corner whatever the
|
|
748
|
+
view does, or the axes' own units, which pins it to a temperature. The
|
|
749
|
+
numbers are CONVERTED when the space changes, so switching never moves
|
|
750
|
+
anything.
|
|
751
|
+
"""
|
|
752
|
+
|
|
753
|
+
def __init__(self, artist, plot, on_change=None, parent=None):
|
|
754
|
+
QWidget.__init__(self, parent)
|
|
755
|
+
self.artist = artist
|
|
756
|
+
self.plot = plot
|
|
757
|
+
self.on_change = on_change
|
|
758
|
+
form = QFormLayout(self)
|
|
759
|
+
form.setContentsMargins(0, 0, 0, 0)
|
|
760
|
+
|
|
761
|
+
self.space = QComboBox()
|
|
762
|
+
self.space.addItem("fraction of the plot (0 to 1)",
|
|
763
|
+
units_module.SPACE_RELATIVE)
|
|
764
|
+
self.space.addItem("the axes' own units", units_module.SPACE_DATA)
|
|
765
|
+
self.space.setCurrentIndex(
|
|
766
|
+
0 if artist.space == units_module.SPACE_RELATIVE else 1)
|
|
767
|
+
form.addRow("Position in", self.space)
|
|
768
|
+
|
|
769
|
+
row = QWidget(self)
|
|
770
|
+
row_layout = QHBoxLayout(row)
|
|
771
|
+
row_layout.setContentsMargins(0, 0, 0, 0)
|
|
772
|
+
self.at_x = NumberBox()
|
|
773
|
+
self.at_y = NumberBox()
|
|
774
|
+
for box in (self.at_x, self.at_y):
|
|
775
|
+
box.setDecimals(4)
|
|
776
|
+
box.setRange(-1e9, 1e9)
|
|
777
|
+
box.setSingleStep(0.01)
|
|
778
|
+
row_layout.addWidget(box)
|
|
779
|
+
self.at_x.setValue(float(artist.x))
|
|
780
|
+
self.at_y.setValue(float(artist.y))
|
|
781
|
+
form.addRow("x, y", row)
|
|
782
|
+
self._form, self._place_row = form, row
|
|
783
|
+
|
|
784
|
+
self.anchor = QComboBox()
|
|
785
|
+
for name in units_module.ANCHORS:
|
|
786
|
+
self.anchor.addItem(name, name)
|
|
787
|
+
index = self.anchor.findData(artist.anchor)
|
|
788
|
+
self.anchor.setCurrentIndex(max(0, index))
|
|
789
|
+
self.anchor.setToolTip("The point of the object that sits on "
|
|
790
|
+
"its position.")
|
|
791
|
+
self.rotation = None
|
|
792
|
+
if getattr(artist, "can_rotate", False):
|
|
793
|
+
self.rotation = NumberBox()
|
|
794
|
+
self.rotation.setDecimals(1)
|
|
795
|
+
self.rotation.setRange(-360.0, 360.0)
|
|
796
|
+
self.rotation.setSuffix(" \u00b0")
|
|
797
|
+
self.rotation.setValue(float(getattr(artist, "rotation", 0.0)))
|
|
798
|
+
self.rotation.setToolTip("Counter-clockwise, about the anchor. "
|
|
799
|
+
"R turns it by hand.")
|
|
800
|
+
form.addRow("Rotation", self.rotation)
|
|
801
|
+
self.rotation.valueChanged.connect(self._apply)
|
|
802
|
+
self.space.setToolTip("Fraction of the plot, or axis units.")
|
|
803
|
+
self.at_x.setToolTip("Position, in the space above.")
|
|
804
|
+
self.at_y.setToolTip("Position, in the space above.")
|
|
805
|
+
form.addRow("Anchor", self.anchor)
|
|
806
|
+
|
|
807
|
+
self.space.currentIndexChanged.connect(self._space_changed)
|
|
808
|
+
self.at_x.valueChanged.connect(self._apply)
|
|
809
|
+
self.at_y.valueChanged.connect(self._apply)
|
|
810
|
+
self.anchor.currentIndexChanged.connect(self._apply)
|
|
811
|
+
|
|
812
|
+
def hide_position(self, anchor_too=False):
|
|
813
|
+
"""No page position to type: a label hanging from its curve is
|
|
814
|
+
placed by its point and distance instead."""
|
|
815
|
+
for widget in (self.space, self._place_row) + (
|
|
816
|
+
(self.anchor,) if anchor_too else ()):
|
|
817
|
+
label = self._form.labelForField(widget)
|
|
818
|
+
if label is not None:
|
|
819
|
+
label.setVisible(False)
|
|
820
|
+
widget.setVisible(False)
|
|
821
|
+
|
|
822
|
+
def _space_changed(self, _index=0):
|
|
823
|
+
"""Convert the stored position so the artist does not jump."""
|
|
824
|
+
wanted = self.space.currentData()
|
|
825
|
+
if wanted != self.artist.space and self.plot is not None:
|
|
826
|
+
self.plot.convert_artist_space(self.artist, wanted)
|
|
827
|
+
self.at_x.blockSignals(True)
|
|
828
|
+
self.at_y.blockSignals(True)
|
|
829
|
+
self.at_x.setValue(float(self.artist.x))
|
|
830
|
+
self.at_y.setValue(float(self.artist.y))
|
|
831
|
+
self.at_x.blockSignals(False)
|
|
832
|
+
self.at_y.blockSignals(False)
|
|
833
|
+
self._apply()
|
|
834
|
+
|
|
835
|
+
def _apply(self, *_args):
|
|
836
|
+
self.artist.space = self.space.currentData()
|
|
837
|
+
self.artist.anchor = self.anchor.currentData()
|
|
838
|
+
self.artist.x = float(self.at_x.value())
|
|
839
|
+
self.artist.y = float(self.at_y.value())
|
|
840
|
+
if self.rotation is not None:
|
|
841
|
+
self.artist.rotation = float(self.rotation.value())
|
|
842
|
+
if self.on_change is not None:
|
|
843
|
+
self.on_change()
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def screen_limit(widget):
|
|
847
|
+
"""The tallest a settings window may be on the screen it is on, in
|
|
848
|
+
pixels, or None when there is no screen to ask."""
|
|
849
|
+
screen = widget.screen() if hasattr(widget, "screen") else None
|
|
850
|
+
if screen is None:
|
|
851
|
+
from PySide6.QtGui import QGuiApplication
|
|
852
|
+
screen = QGuiApplication.primaryScreen()
|
|
853
|
+
if screen is None:
|
|
854
|
+
return None
|
|
855
|
+
return int(screen.availableGeometry().height() * SCREEN_SHARE)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
class _LiveDialog(QDialog):
|
|
859
|
+
"""Common machinery: snapshot on open, restore on reject.
|
|
860
|
+
|
|
861
|
+
One dialog can edit SEVERAL objects of its kind at once (`set_group`:
|
|
862
|
+
select three onsets, open the settings, set the size of all three). It
|
|
863
|
+
shows the object it was opened on and MIRRORS every change onto the
|
|
864
|
+
others: whatever field of the shown object just
|
|
865
|
+
changed is copied to the rest, and nothing else is touched, so their
|
|
866
|
+
own values of every other field stay theirs. Fields that belong to one
|
|
867
|
+
object alone (`INDIVIDUAL`: a label's text, a position) are not mirrored
|
|
868
|
+
and their widgets (`GROUP_DISABLED`) are greyed out.
|
|
869
|
+
"""
|
|
870
|
+
|
|
871
|
+
#: Attribute names this dialog edits, for the snapshot.
|
|
872
|
+
FIELDS = ()
|
|
873
|
+
#: Fields that are one object's own, never set for a group.
|
|
874
|
+
INDIVIDUAL = ()
|
|
875
|
+
#: Widgets (attribute names on the dialog, dotted for a nested one)
|
|
876
|
+
#: greyed out while a group is edited.
|
|
877
|
+
GROUP_DISABLED = ()
|
|
878
|
+
#: True for an object with a place in the figure's stack: its window
|
|
879
|
+
#: gets a Layer field (`_layer_row`), and its z is in FIELDS.
|
|
880
|
+
LAYERED = False
|
|
881
|
+
|
|
882
|
+
def __init__(self, parent, obj, on_change=None):
|
|
883
|
+
QDialog.__init__(self, parent)
|
|
884
|
+
if self.LAYERED and "z" not in type(self).FIELDS:
|
|
885
|
+
self.FIELDS = tuple(type(self).FIELDS) + ("z",)
|
|
886
|
+
self.obj = obj
|
|
887
|
+
self.on_change = on_change
|
|
888
|
+
self.doc = _document_of(parent)
|
|
889
|
+
self._snapshot = {name: getattr(obj, name) for name in self.FIELDS}
|
|
890
|
+
#: The OTHER objects edited along with `obj`, and what they were.
|
|
891
|
+
self.group = []
|
|
892
|
+
self._group_snapshots = []
|
|
893
|
+
self._last = dict(self._snapshot)
|
|
894
|
+
# NOT MODAL. These dialogs apply as they are touched and are meant to
|
|
895
|
+
# be worked beside: a dialog that blocks the plot blocks the
|
|
896
|
+
# measurement cursors it is describing, which is exactly what
|
|
897
|
+
# goes wrong when adjusting an analysis. `Qt.Tool` keeps it above
|
|
898
|
+
# the window it belongs to without taking the focus away from it.
|
|
899
|
+
self.setModal(False)
|
|
900
|
+
self.setWindowFlag(Qt.Tool, True)
|
|
901
|
+
self.setAttribute(Qt.WA_DeleteOnClose, False)
|
|
902
|
+
|
|
903
|
+
def set_group(self, others):
|
|
904
|
+
"""Edit `others` along with the object this was opened on."""
|
|
905
|
+
self.group = [o for o in others if o is not self.obj]
|
|
906
|
+
self._group_snapshots = [
|
|
907
|
+
(o, {name: getattr(o, name) for name in self.FIELDS})
|
|
908
|
+
for o in self.group]
|
|
909
|
+
self._last = {name: getattr(self.obj, name) for name in self.FIELDS}
|
|
910
|
+
for path in self.GROUP_DISABLED:
|
|
911
|
+
widget = self
|
|
912
|
+
for part in path.split("."):
|
|
913
|
+
widget = getattr(widget, part, None)
|
|
914
|
+
if widget is not None:
|
|
915
|
+
widget.setEnabled(False)
|
|
916
|
+
widget.setToolTip("Per object: not set for a group.")
|
|
917
|
+
if self.group:
|
|
918
|
+
self.setWindowTitle("{} (and {} more)".format(
|
|
919
|
+
self.windowTitle(), len(self.group)))
|
|
920
|
+
return self
|
|
921
|
+
|
|
922
|
+
def _mirror(self):
|
|
923
|
+
"""Copy what just changed on the shown object to the rest."""
|
|
924
|
+
for name in self.FIELDS:
|
|
925
|
+
value = getattr(self.obj, name)
|
|
926
|
+
if value == self._last.get(name):
|
|
927
|
+
continue
|
|
928
|
+
self._last[name] = value
|
|
929
|
+
if name in self.INDIVIDUAL:
|
|
930
|
+
continue
|
|
931
|
+
for other in self.group:
|
|
932
|
+
setattr(other, name, value)
|
|
933
|
+
|
|
934
|
+
def _live(self, *_args):
|
|
935
|
+
if self.group:
|
|
936
|
+
self._mirror()
|
|
937
|
+
if self.on_change is not None:
|
|
938
|
+
self.on_change()
|
|
939
|
+
|
|
940
|
+
def keyPressEvent(self, ev):
|
|
941
|
+
if enter_stays(self, ev):
|
|
942
|
+
return
|
|
943
|
+
QDialog.keyPressEvent(self, ev)
|
|
944
|
+
|
|
945
|
+
def showEvent(self, ev):
|
|
946
|
+
readable(self)
|
|
947
|
+
QDialog.showEvent(self, ev)
|
|
948
|
+
self.fit()
|
|
949
|
+
|
|
950
|
+
def fit(self):
|
|
951
|
+
"""Grow to what the rows need at this width. A wrapped label that
|
|
952
|
+
is filled in after the window was sized - an analysis's results, a
|
|
953
|
+
problem under its label - was cut off. Never past `SCREEN_SHARE` of
|
|
954
|
+
the screen's height: beyond that the rows scroll
|
|
955
|
+
(`_scroll_rows`)."""
|
|
956
|
+
layout = self.layout()
|
|
957
|
+
if layout is None:
|
|
958
|
+
return
|
|
959
|
+
if getattr(self, "_rows_area", None) is not None:
|
|
960
|
+
self._fit_scrolled()
|
|
961
|
+
return
|
|
962
|
+
layout.setSizeConstraint(QLayout.SetMinimumSize)
|
|
963
|
+
layout.activate()
|
|
964
|
+
needed = (layout.totalHeightForWidth(self.width())
|
|
965
|
+
if layout.hasHeightForWidth()
|
|
966
|
+
else self.sizeHint().height())
|
|
967
|
+
limit = screen_limit(self)
|
|
968
|
+
if limit is not None and needed > limit:
|
|
969
|
+
self._scroll_rows()
|
|
970
|
+
self._fit_scrolled()
|
|
971
|
+
return
|
|
972
|
+
if needed > self.height():
|
|
973
|
+
self.resize(self.width(), needed)
|
|
974
|
+
|
|
975
|
+
def _scroll_rows(self):
|
|
976
|
+
"""Put the rows in a scroll area, keeping the buttons (the last
|
|
977
|
+
row, when it is a button box) below it, always in view."""
|
|
978
|
+
layout = self.layout()
|
|
979
|
+
buttons = None
|
|
980
|
+
last = layout.itemAt(layout.count() - 1) if layout.count() else None
|
|
981
|
+
if last is not None and isinstance(last.widget(), QDialogButtonBox):
|
|
982
|
+
buttons = last.widget()
|
|
983
|
+
layout.removeWidget(buttons)
|
|
984
|
+
inner = QWidget()
|
|
985
|
+
# Takes the layout, and its widgets, off this dialog.
|
|
986
|
+
inner.setLayout(layout)
|
|
987
|
+
area = QScrollArea(self)
|
|
988
|
+
area.setWidgetResizable(True)
|
|
989
|
+
area.setFrameShape(QScrollArea.NoFrame)
|
|
990
|
+
area.setWidget(inner)
|
|
991
|
+
outer = QVBoxLayout(self)
|
|
992
|
+
outer.setContentsMargins(0, 0, 0, 0)
|
|
993
|
+
outer.addWidget(area, 1)
|
|
994
|
+
if buttons is not None:
|
|
995
|
+
row = QHBoxLayout()
|
|
996
|
+
row.setContentsMargins(9, 0, 9, 9)
|
|
997
|
+
row.addWidget(buttons)
|
|
998
|
+
outer.addLayout(row)
|
|
999
|
+
self._rows_area = area
|
|
1000
|
+
self._rows_inner = inner
|
|
1001
|
+
# `fit` had pinned this window's minimum to the rows' full height
|
|
1002
|
+
# (SetMinimumSize on the layout now inside the area): let it go.
|
|
1003
|
+
self.setMinimumSize(0, 0)
|
|
1004
|
+
|
|
1005
|
+
def _fit_scrolled(self):
|
|
1006
|
+
limit = screen_limit(self) or 600
|
|
1007
|
+
inner = self._rows_inner
|
|
1008
|
+
needed = inner.sizeHint().height() + 60
|
|
1009
|
+
bar = self._rows_area.verticalScrollBar().sizeHint().width()
|
|
1010
|
+
width = max(self.width(), inner.sizeHint().width() + bar + 4)
|
|
1011
|
+
self.resize(width, min(limit, needed))
|
|
1012
|
+
|
|
1013
|
+
def reject(self):
|
|
1014
|
+
"""Closed without OK - its X, Esc, Ctrl+W: KEEP what was changed.
|
|
1015
|
+
|
|
1016
|
+
These apply as they are touched, and a change is a change.
|
|
1017
|
+
Putting everything back on closing would, after a minute of
|
|
1018
|
+
adjusting by eye, be exactly the loss nobody expects. Only the
|
|
1019
|
+
Revert button puts things back (`revert`); either way an undo step
|
|
1020
|
+
is made, so Ctrl+Z still takes the whole dialog back afterwards.
|
|
1021
|
+
"""
|
|
1022
|
+
self.accept()
|
|
1023
|
+
|
|
1024
|
+
def revert(self):
|
|
1025
|
+
"""The Revert button: put back what the objects were when this
|
|
1026
|
+
opened."""
|
|
1027
|
+
for name, value in self._snapshot.items():
|
|
1028
|
+
setattr(self.obj, name, value)
|
|
1029
|
+
for other, saved in self._group_snapshots:
|
|
1030
|
+
for name, value in saved.items():
|
|
1031
|
+
setattr(other, name, value)
|
|
1032
|
+
self._last = dict(self._snapshot)
|
|
1033
|
+
if self.on_change is not None:
|
|
1034
|
+
self.on_change()
|
|
1035
|
+
QDialog.reject(self)
|
|
1036
|
+
|
|
1037
|
+
def _layer_row(self, form):
|
|
1038
|
+
"""Layer: where the object is drawn in the stack, as a number -
|
|
1039
|
+
the same order Ctrl+PgUp and Ctrl+PgDown move it in."""
|
|
1040
|
+
self.layer = NumberBox()
|
|
1041
|
+
self.layer.setDecimals(1)
|
|
1042
|
+
self.layer.setRange(-10000.0, 10000.0)
|
|
1043
|
+
self.layer.setSingleStep(1.0)
|
|
1044
|
+
self.layer.setValue(float(units_module.z_of(self.obj)))
|
|
1045
|
+
self.layer.setToolTip("Higher is drawn on top. Ctrl+PgUp / PgDn "
|
|
1046
|
+
"move it one place.")
|
|
1047
|
+
form.addRow("Layer", self.layer)
|
|
1048
|
+
self.layer.valueChanged.connect(self._set_layer)
|
|
1049
|
+
|
|
1050
|
+
def _set_layer(self, value):
|
|
1051
|
+
self.obj.z = float(value)
|
|
1052
|
+
self._live()
|
|
1053
|
+
|
|
1054
|
+
def _buttons(self):
|
|
1055
|
+
"""OK and Revert. OK closes; Revert undoes this dialog and closes.
|
|
1056
|
+
|
|
1057
|
+
Also where a window for an object in the stack gets its Layer row,
|
|
1058
|
+
at the end of its form: every such window builds its buttons last."""
|
|
1059
|
+
if self.LAYERED and not hasattr(self, "layer"):
|
|
1060
|
+
forms = self.findChildren(QFormLayout)
|
|
1061
|
+
if forms:
|
|
1062
|
+
self._layer_row(forms[0])
|
|
1063
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Ok
|
|
1064
|
+
| QDialogButtonBox.Cancel)
|
|
1065
|
+
revert = buttons.button(QDialogButtonBox.Cancel)
|
|
1066
|
+
revert.setText("Revert")
|
|
1067
|
+
revert.setToolTip("Undo this window's changes and close. "
|
|
1068
|
+
"Closing otherwise keeps them.")
|
|
1069
|
+
buttons.accepted.connect(self.accept)
|
|
1070
|
+
buttons.rejected.connect(self.revert)
|
|
1071
|
+
return buttons
|
|
1072
|
+
|
|
1073
|
+
def snapshot(self):
|
|
1074
|
+
"""What the object looked like when this opened, for the undo step."""
|
|
1075
|
+
return dict(self._snapshot)
|
|
1076
|
+
|
|
1077
|
+
def snapshots(self):
|
|
1078
|
+
"""`[(obj, {field: value when opened}), ...]` for every object this
|
|
1079
|
+
dialog edits, the shown one first."""
|
|
1080
|
+
return ([(self.obj, dict(self._snapshot))]
|
|
1081
|
+
+ [(o, dict(saved)) for o, saved in self._group_snapshots])
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _colour_button(parent, get_colour, set_colour):
|
|
1085
|
+
button = QPushButton(parent)
|
|
1086
|
+
button.setFixedWidth(64)
|
|
1087
|
+
|
|
1088
|
+
def refresh():
|
|
1089
|
+
colour = QColor(get_colour())
|
|
1090
|
+
button.setStyleSheet(
|
|
1091
|
+
"background: {}; border: 1px solid #555;".format(colour.name()))
|
|
1092
|
+
|
|
1093
|
+
def live(name):
|
|
1094
|
+
set_colour(name)
|
|
1095
|
+
refresh()
|
|
1096
|
+
|
|
1097
|
+
def pick():
|
|
1098
|
+
# Live: the figure follows the wheel. Revert puts the colour back,
|
|
1099
|
+
# and a "Follow the theme" / "Same as scan" that was ticked.
|
|
1100
|
+
auto = getattr(parent, "auto", None)
|
|
1101
|
+
was_auto = auto is not None and auto.isChecked()
|
|
1102
|
+
colour = pick_colour(QColor(get_colour()), parent, "Pick a colour",
|
|
1103
|
+
live=live)
|
|
1104
|
+
if colour.isValid():
|
|
1105
|
+
set_colour(colour.name())
|
|
1106
|
+
elif was_auto:
|
|
1107
|
+
auto.setChecked(True)
|
|
1108
|
+
refresh()
|
|
1109
|
+
|
|
1110
|
+
button.clicked.connect(lambda _c=False: pick())
|
|
1111
|
+
refresh()
|
|
1112
|
+
return button
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
class ScanSettings(_LiveDialog):
|
|
1116
|
+
"""Everything about one scan, plus its sample's molar mass."""
|
|
1117
|
+
|
|
1118
|
+
FIELDS = ("colour", "label", "offset", "line_width", "keep",
|
|
1119
|
+
"dtg_window")
|
|
1120
|
+
INDIVIDUAL = ("label", "offset")
|
|
1121
|
+
GROUP_DISABLED = ("label", "offset", "molar", "analyses")
|
|
1122
|
+
|
|
1123
|
+
def __init__(self, parent, scan, unit, on_change=None):
|
|
1124
|
+
_LiveDialog.__init__(self, parent, scan, on_change)
|
|
1125
|
+
self.setWindowTitle("Settings for {}".format(scan.display_name()))
|
|
1126
|
+
self._sample_molar_mass = scan.sample.molar_mass
|
|
1127
|
+
layout = QVBoxLayout(self)
|
|
1128
|
+
form = QFormLayout()
|
|
1129
|
+
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
1130
|
+
layout.addLayout(form)
|
|
1131
|
+
|
|
1132
|
+
self.label = QLineEdit(scan.label or "")
|
|
1133
|
+
self.label.setPlaceholderText(scan.display_name())
|
|
1134
|
+
self.label.setToolTip("Name in the legend. Empty: the segment's "
|
|
1135
|
+
"program.")
|
|
1136
|
+
form.addRow("Label", self.label)
|
|
1137
|
+
|
|
1138
|
+
form.addRow("Colour", _colour_button(
|
|
1139
|
+
self, lambda: scan.colour, self._set_colour))
|
|
1140
|
+
|
|
1141
|
+
self.offset = NumberBox()
|
|
1142
|
+
self.offset.setDecimals(6)
|
|
1143
|
+
self.offset.setRange(-1e9, 1e9)
|
|
1144
|
+
self.offset.setValue(float(scan.offset))
|
|
1145
|
+
self.offset.setSuffix(" " + unit)
|
|
1146
|
+
self.offset.setToolTip("Vertical offset, in the y unit.")
|
|
1147
|
+
form.addRow("Offset", self.offset)
|
|
1148
|
+
|
|
1149
|
+
self.line_width = _style_number(self, scan, "line_width")
|
|
1150
|
+
form.addRow("Line width", self.line_width)
|
|
1151
|
+
|
|
1152
|
+
# A DTG's smoothing: the window of the local slope, in kelvin of
|
|
1153
|
+
# the ramp (`core/dtg.py`). Only a DTG has one.
|
|
1154
|
+
self.dtg_window = NumberBox()
|
|
1155
|
+
self.dtg_window.setDecimals(1)
|
|
1156
|
+
self.dtg_window.setRange(0.0, 100.0)
|
|
1157
|
+
self.dtg_window.setSingleStep(0.5)
|
|
1158
|
+
self.dtg_window.setSuffix(" K")
|
|
1159
|
+
self.dtg_window.setValue(float(getattr(scan, "dtg_window", 0.0)))
|
|
1160
|
+
self.dtg_window.setToolTip("Smoothing window of the derivative, in "
|
|
1161
|
+
"kelvin of the ramp. 0 is none.")
|
|
1162
|
+
self.dtg_window.valueChanged.connect(lambda _v: self._apply())
|
|
1163
|
+
if getattr(scan, "is_dtg", False):
|
|
1164
|
+
form.addRow("Smoothing", self.dtg_window)
|
|
1165
|
+
else:
|
|
1166
|
+
self.dtg_window.setVisible(False)
|
|
1167
|
+
|
|
1168
|
+
# The template's x_truncate: hide the ends by POSITION along the
|
|
1169
|
+
# curve, never by temperature (a segment doubles back at its start).
|
|
1170
|
+
cut = QWidget(self)
|
|
1171
|
+
cut_row = QHBoxLayout(cut)
|
|
1172
|
+
cut_row.setContentsMargins(0, 0, 0, 0)
|
|
1173
|
+
self.cut_start = NumberBox()
|
|
1174
|
+
self.cut_end = NumberBox()
|
|
1175
|
+
for box, value in ((self.cut_start, scan.keep[0]),
|
|
1176
|
+
(self.cut_end, 1.0 - scan.keep[1])):
|
|
1177
|
+
box.setDecimals(1)
|
|
1178
|
+
box.setRange(0.0, 49.0)
|
|
1179
|
+
box.setSingleStep(0.5)
|
|
1180
|
+
box.setSuffix(" %")
|
|
1181
|
+
box.setValue(round(100.0 * float(value), 1))
|
|
1182
|
+
cut_row.addWidget(QLabel("first"))
|
|
1183
|
+
cut_row.addWidget(self.cut_start, 1)
|
|
1184
|
+
cut_row.addWidget(QLabel("last"))
|
|
1185
|
+
cut_row.addWidget(self.cut_end, 1)
|
|
1186
|
+
cut.setToolTip("Hide the curve's ends, by share of its points. "
|
|
1187
|
+
"Hidden ends are left out of fits, analyses and "
|
|
1188
|
+
"exports.")
|
|
1189
|
+
form.addRow("Hide", cut)
|
|
1190
|
+
self.cut_note = QLabel("")
|
|
1191
|
+
self.cut_note.setStyleSheet("color: #9a9a9a;")
|
|
1192
|
+
form.addRow("", self.cut_note)
|
|
1193
|
+
self._describe_cut()
|
|
1194
|
+
|
|
1195
|
+
# The analyses, one box each, off unless ticked. A run carries a
|
|
1196
|
+
# dozen and a figure wants one or two, so the list is the dialogue
|
|
1197
|
+
# rather than a single "show them all" switch. Double-click opens an
|
|
1198
|
+
# analysis's own settings.
|
|
1199
|
+
self.analyses = QListWidget(self)
|
|
1200
|
+
self.analyses.setMaximumHeight(120)
|
|
1201
|
+
self.analyses.setToolTip("Tick to show. Double-click for settings.")
|
|
1202
|
+
for analysis in scan.analysis_objects:
|
|
1203
|
+
entry = QListWidgetItem(self._analysis_text(analysis))
|
|
1204
|
+
entry.setFlags(entry.flags() | Qt.ItemIsUserCheckable)
|
|
1205
|
+
entry.setCheckState(Qt.Checked if analysis.visible
|
|
1206
|
+
else Qt.Unchecked)
|
|
1207
|
+
entry.setData(Qt.UserRole, analysis)
|
|
1208
|
+
self.analyses.addItem(entry)
|
|
1209
|
+
if not scan.analysis_objects:
|
|
1210
|
+
self.analyses.addItem("no analyses in the file for this scan")
|
|
1211
|
+
self.analyses.setEnabled(False)
|
|
1212
|
+
form.addRow("Analyses", self.analyses)
|
|
1213
|
+
|
|
1214
|
+
# ------------------------------------------------- the molar mass
|
|
1215
|
+
sample = scan.sample
|
|
1216
|
+
if not sample.mass_g:
|
|
1217
|
+
said = "not in the file"
|
|
1218
|
+
elif sample.mass_source == "derived from the weight":
|
|
1219
|
+
said = sample.mass_text()
|
|
1220
|
+
else:
|
|
1221
|
+
said = "{:g} mg (from the file)".format(sample.mass_g * 1000.0)
|
|
1222
|
+
form.addRow("Sample mass", QLabel(said))
|
|
1223
|
+
|
|
1224
|
+
self.molar = NumberBox()
|
|
1225
|
+
self.molar.setDecimals(4)
|
|
1226
|
+
self.molar.setRange(0.0, 1e7)
|
|
1227
|
+
self.molar.setSpecialValueText("not given")
|
|
1228
|
+
self.molar.setSuffix(" g/mol")
|
|
1229
|
+
self.molar.setValue(float(scan.sample.molar_mass or 0.0))
|
|
1230
|
+
self.molar.setToolTip("Needed for W/mol and kJ/mol. Never "
|
|
1231
|
+
"assumed. Every scan of the file has it.")
|
|
1232
|
+
form.addRow("Molar mass", molar_row(self, self.molar, scan.sample))
|
|
1233
|
+
|
|
1234
|
+
buttons = self._buttons()
|
|
1235
|
+
layout.addWidget(buttons)
|
|
1236
|
+
|
|
1237
|
+
self.label.textChanged.connect(self._apply)
|
|
1238
|
+
for box in (self.offset, self.molar):
|
|
1239
|
+
box.valueChanged.connect(self._apply)
|
|
1240
|
+
self.line_width.changed.connect(self._apply)
|
|
1241
|
+
self.cut_start.valueChanged.connect(self._apply)
|
|
1242
|
+
self.cut_end.valueChanged.connect(self._apply)
|
|
1243
|
+
self.analyses.itemChanged.connect(self._analysis_ticked)
|
|
1244
|
+
self.analyses.itemDoubleClicked.connect(self._edit_analysis)
|
|
1245
|
+
self.resize(440, self.sizeHint().height())
|
|
1246
|
+
|
|
1247
|
+
def _describe_cut(self):
|
|
1248
|
+
"""Where the drawn part of the curve now starts and ends."""
|
|
1249
|
+
temperature = self.obj.temperature()
|
|
1250
|
+
if temperature is None or not len(temperature):
|
|
1251
|
+
self.cut_note.setText("")
|
|
1252
|
+
return
|
|
1253
|
+
k0, k1 = self.obj.kept_range(len(temperature))
|
|
1254
|
+
self.cut_note.setText(
|
|
1255
|
+
"drawn from {:.1f} to {:.1f} \u00b0C ({} of {} points)".format(
|
|
1256
|
+
float(temperature[k0]), float(temperature[k1 - 1]),
|
|
1257
|
+
k1 - k0, len(temperature)))
|
|
1258
|
+
|
|
1259
|
+
@staticmethod
|
|
1260
|
+
def _analysis_text(analysis):
|
|
1261
|
+
text = analysis.summary()
|
|
1262
|
+
if not analysis.certain:
|
|
1263
|
+
text += " ({})".format(analysis.attribution)
|
|
1264
|
+
elif not analysis.decoded:
|
|
1265
|
+
text += " (cursors only)"
|
|
1266
|
+
return text
|
|
1267
|
+
|
|
1268
|
+
def _analysis_ticked(self, item):
|
|
1269
|
+
analysis = item.data(Qt.UserRole)
|
|
1270
|
+
if analysis is None:
|
|
1271
|
+
return
|
|
1272
|
+
analysis.visible = item.checkState() == Qt.Checked
|
|
1273
|
+
self._live()
|
|
1274
|
+
|
|
1275
|
+
def _edit_analysis(self, item):
|
|
1276
|
+
"""Open one analysis's own settings from this list."""
|
|
1277
|
+
analysis = item.data(Qt.UserRole)
|
|
1278
|
+
if analysis is None:
|
|
1279
|
+
return
|
|
1280
|
+
dialog = AnalysisSettings(self, analysis, on_change=self.on_change)
|
|
1281
|
+
# Shown, not `exec`ed: `exec` is modal whatever the dialog says, and
|
|
1282
|
+
# these are meant to be worked beside the plot. The row is refreshed
|
|
1283
|
+
# when it closes rather than on the next line.
|
|
1284
|
+
self._analysis_dialog = dialog
|
|
1285
|
+
dialog.finished.connect(
|
|
1286
|
+
lambda _result, it=item, an=analysis: self._analysis_edited(it, an))
|
|
1287
|
+
dialog.show()
|
|
1288
|
+
dialog.raise_()
|
|
1289
|
+
|
|
1290
|
+
def _analysis_edited(self, item, analysis):
|
|
1291
|
+
item.setText(self._analysis_text(analysis))
|
|
1292
|
+
item.setCheckState(Qt.Checked if analysis.visible else Qt.Unchecked)
|
|
1293
|
+
self._live()
|
|
1294
|
+
|
|
1295
|
+
def _set_colour(self, name):
|
|
1296
|
+
self.obj.colour = name
|
|
1297
|
+
self._live()
|
|
1298
|
+
|
|
1299
|
+
def _apply(self, *_args):
|
|
1300
|
+
scan = self.obj
|
|
1301
|
+
scan.label = self.label.text().strip() or None
|
|
1302
|
+
scan.offset = float(self.offset.value())
|
|
1303
|
+
scan.line_width = self.line_width.value()
|
|
1304
|
+
scan.keep = (round(self.cut_start.value() / 100.0, 6),
|
|
1305
|
+
round(1.0 - self.cut_end.value() / 100.0, 6))
|
|
1306
|
+
self._describe_cut()
|
|
1307
|
+
if getattr(scan, "is_dtg", False):
|
|
1308
|
+
scan.dtg_window = float(self.dtg_window.value())
|
|
1309
|
+
scan.sample.molar_mass = (float(self.molar.value())
|
|
1310
|
+
if self.molar.value() > 0 else None)
|
|
1311
|
+
scan._cache_key = None
|
|
1312
|
+
self._live()
|
|
1313
|
+
|
|
1314
|
+
def revert(self):
|
|
1315
|
+
self.obj.sample.molar_mass = self._sample_molar_mass
|
|
1316
|
+
_LiveDialog.revert(self)
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def molar_row(dialog, box, sample):
|
|
1320
|
+
"""A molar mass box with its calculator beside it."""
|
|
1321
|
+
row = QWidget(dialog)
|
|
1322
|
+
line = QHBoxLayout(row)
|
|
1323
|
+
line.setContentsMargins(0, 0, 0, 0)
|
|
1324
|
+
line.addWidget(box, 1)
|
|
1325
|
+
button = QPushButton("Calculate...", row)
|
|
1326
|
+
button.setAutoDefault(False)
|
|
1327
|
+
button.setToolTip("From a sum formula, a SMILES or a composition "
|
|
1328
|
+
"(Hbc)0.75+Zn(im)1.70(bim)0.30.")
|
|
1329
|
+
|
|
1330
|
+
def calculate(_checked=False):
|
|
1331
|
+
calculator = MolarMassDialog(sample.composition or "", dialog)
|
|
1332
|
+
if calculator.exec() and calculator.mass() is not None:
|
|
1333
|
+
sample.composition = calculator.text() or None
|
|
1334
|
+
box.setValue(round(calculator.mass(), 4))
|
|
1335
|
+
|
|
1336
|
+
button.clicked.connect(calculate)
|
|
1337
|
+
line.addWidget(button, 0)
|
|
1338
|
+
dialog.calculate_molar = calculate
|
|
1339
|
+
return row
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
class MolarMassDialog(QDialog):
|
|
1343
|
+
"""The molar mass calculator: a sum formula (C6H6), a SMILES, or a
|
|
1344
|
+
composition of (key)coefficient parts joined by +, read the way that
|
|
1345
|
+
fits or as chosen, with the formula and the mass shown as it is typed.
|
|
1346
|
+
`core/molar.py` does the chemistry."""
|
|
1347
|
+
|
|
1348
|
+
def __init__(self, text="", parent=None):
|
|
1349
|
+
QDialog.__init__(self, parent)
|
|
1350
|
+
self.setWindowTitle("Molar mass")
|
|
1351
|
+
layout = QVBoxLayout(self)
|
|
1352
|
+
form = QFormLayout()
|
|
1353
|
+
layout.addLayout(form)
|
|
1354
|
+
self.entry = QLineEdit(text, self)
|
|
1355
|
+
self.entry.setPlaceholderText("C6H6, O=C(O)c1ccccc1, or "
|
|
1356
|
+
"(Hbc)0.75+Zn(im)1.70(bim)0.30")
|
|
1357
|
+
self.entry.setToolTip("A sum formula (brackets, decimals, * for an "
|
|
1358
|
+
"adduct), a SMILES, or a composition: "
|
|
1359
|
+
"(key)coefficient parts joined by +.")
|
|
1360
|
+
form.addRow("From", self.entry)
|
|
1361
|
+
self.reading = QComboBox(self)
|
|
1362
|
+
for name in molar.READINGS:
|
|
1363
|
+
self.reading.addItem(molar.TITLES[name], name)
|
|
1364
|
+
self.reading.setToolTip("CO is carbon monoxide as a formula and "
|
|
1365
|
+
"methanol as a SMILES: choose when it "
|
|
1366
|
+
"matters.")
|
|
1367
|
+
form.addRow("Read as", self.reading)
|
|
1368
|
+
self.formula = QLabel("", self)
|
|
1369
|
+
self.formula.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
1370
|
+
form.addRow("Formula", self.formula)
|
|
1371
|
+
self.mass_text = QLabel("", self)
|
|
1372
|
+
self.mass_text.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
1373
|
+
form.addRow("Molar mass", self.mass_text)
|
|
1374
|
+
blocks = QLabel("Building blocks: " + ", ".join(
|
|
1375
|
+
molar.BUILDING_BLOCKS), self)
|
|
1376
|
+
blocks.setWordWrap(True)
|
|
1377
|
+
blocks.setStyleSheet("color: #9a9a9a;")
|
|
1378
|
+
layout.addWidget(blocks)
|
|
1379
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Ok
|
|
1380
|
+
| QDialogButtonBox.Cancel)
|
|
1381
|
+
self.use = buttons.button(QDialogButtonBox.Ok)
|
|
1382
|
+
self.use.setText("Use")
|
|
1383
|
+
buttons.accepted.connect(self.accept)
|
|
1384
|
+
buttons.rejected.connect(self.reject)
|
|
1385
|
+
layout.addWidget(buttons)
|
|
1386
|
+
self.entry.textChanged.connect(lambda _t: self.work_out())
|
|
1387
|
+
self.reading.currentIndexChanged.connect(lambda _i: self.work_out())
|
|
1388
|
+
self._found = None
|
|
1389
|
+
self.work_out()
|
|
1390
|
+
self.resize(460, self.sizeHint().height())
|
|
1391
|
+
|
|
1392
|
+
def text(self):
|
|
1393
|
+
return self.entry.text().strip()
|
|
1394
|
+
|
|
1395
|
+
def work_out(self):
|
|
1396
|
+
text = self.text()
|
|
1397
|
+
if not text:
|
|
1398
|
+
self._found = None
|
|
1399
|
+
self.formula.setText("")
|
|
1400
|
+
self.mass_text.setText("")
|
|
1401
|
+
self.use.setEnabled(False)
|
|
1402
|
+
return None
|
|
1403
|
+
found = molar.calculate(text, self.reading.currentData())
|
|
1404
|
+
self._found = found
|
|
1405
|
+
if found.ok:
|
|
1406
|
+
self.formula.setText("{} (read as {})".format(
|
|
1407
|
+
found.formula, molar.TITLES.get(found.read, found.read)))
|
|
1408
|
+
self.formula.setStyleSheet("")
|
|
1409
|
+
self.mass_text.setText("{:.2f} g/mol".format(found.mass))
|
|
1410
|
+
else:
|
|
1411
|
+
self.formula.setText(found.error or "")
|
|
1412
|
+
self.formula.setStyleSheet("color: #d04040;")
|
|
1413
|
+
self.mass_text.setText("")
|
|
1414
|
+
self.use.setEnabled(found.ok)
|
|
1415
|
+
return found
|
|
1416
|
+
|
|
1417
|
+
def mass(self):
|
|
1418
|
+
found = self._found
|
|
1419
|
+
return found.mass if found is not None and found.ok else None
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
class _MarginsDialog(QDialog):
|
|
1423
|
+
"""Four margins in numbers, left / right / top / bottom: the page's
|
|
1424
|
+
white margins or the data's inside the axes box. Built here, shown and
|
|
1425
|
+
applied by the window, one undo step."""
|
|
1426
|
+
|
|
1427
|
+
TITLE = "Margins"
|
|
1428
|
+
SIDES = (("left", "Left"), ("right", "Right"), ("top", "Top"),
|
|
1429
|
+
("bottom", "Bottom"))
|
|
1430
|
+
|
|
1431
|
+
def __init__(self, values, parent=None):
|
|
1432
|
+
QDialog.__init__(self, parent)
|
|
1433
|
+
self.setWindowTitle(self.TITLE)
|
|
1434
|
+
layout = QVBoxLayout(self)
|
|
1435
|
+
self.form = QFormLayout()
|
|
1436
|
+
layout.addLayout(self.form)
|
|
1437
|
+
self.boxes = {}
|
|
1438
|
+
for side, words in self.SIDES:
|
|
1439
|
+
box = NumberBox(self)
|
|
1440
|
+
self._shape(box, side)
|
|
1441
|
+
box.setValue(float(values.get(side, 0.0)))
|
|
1442
|
+
box.valueChanged.connect(lambda _v: self._check())
|
|
1443
|
+
self.form.addRow(words, box)
|
|
1444
|
+
self.boxes[side] = box
|
|
1445
|
+
self.note = QLabel("", self)
|
|
1446
|
+
self.note.setWordWrap(True)
|
|
1447
|
+
self.note.setStyleSheet("color: #9a9a9a;")
|
|
1448
|
+
layout.addWidget(self.note)
|
|
1449
|
+
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok
|
|
1450
|
+
| QDialogButtonBox.Cancel)
|
|
1451
|
+
self.buttons.accepted.connect(self.accept)
|
|
1452
|
+
self.buttons.rejected.connect(self.reject)
|
|
1453
|
+
self._extra(self.buttons)
|
|
1454
|
+
layout.addWidget(self.buttons)
|
|
1455
|
+
self._check()
|
|
1456
|
+
|
|
1457
|
+
def _shape(self, box, side):
|
|
1458
|
+
pass
|
|
1459
|
+
|
|
1460
|
+
def _extra(self, buttons):
|
|
1461
|
+
pass
|
|
1462
|
+
|
|
1463
|
+
def _check(self):
|
|
1464
|
+
return True
|
|
1465
|
+
|
|
1466
|
+
def values(self):
|
|
1467
|
+
return dict((side, float(box.value()))
|
|
1468
|
+
for side, box in self.boxes.items())
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
class PageMarginsDialog(_MarginsDialog):
|
|
1472
|
+
"""The page's white margins of an exact figure, in its unit, each no
|
|
1473
|
+
less than what it holds. More white space grows the page; the axes box
|
|
1474
|
+
keeps its size."""
|
|
1475
|
+
|
|
1476
|
+
TITLE = "Page margins"
|
|
1477
|
+
|
|
1478
|
+
def __init__(self, values, least, unit, parent=None):
|
|
1479
|
+
self.least = dict(least)
|
|
1480
|
+
self.unit = unit
|
|
1481
|
+
_MarginsDialog.__init__(self, values, parent)
|
|
1482
|
+
tighten = self.buttons.button(QDialogButtonBox.Reset)
|
|
1483
|
+
tighten.clicked.connect(lambda _c=False: self.tighten())
|
|
1484
|
+
self.note.setText("White space round the axes box. More grows the "
|
|
1485
|
+
"page, less shrinks it; the axes box keeps its "
|
|
1486
|
+
"size. Each is at least what it holds.")
|
|
1487
|
+
|
|
1488
|
+
def _shape(self, box, side):
|
|
1489
|
+
box.setDecimals(2)
|
|
1490
|
+
box.setRange(float(self.least.get(side, 0.0)), 1000.0)
|
|
1491
|
+
box.setSingleStep(0.05)
|
|
1492
|
+
box.setSuffix(" " + self.unit)
|
|
1493
|
+
box.setToolTip("At least {:.2f} {}: less would cut off what it "
|
|
1494
|
+
"holds.".format(self.least.get(side, 0.0), self.unit))
|
|
1495
|
+
|
|
1496
|
+
def _extra(self, buttons):
|
|
1497
|
+
buttons.addButton(QDialogButtonBox.Reset).setText("Tighten")
|
|
1498
|
+
buttons.button(QDialogButtonBox.Reset).setToolTip(
|
|
1499
|
+
"Every margin down to what it holds.")
|
|
1500
|
+
|
|
1501
|
+
def tighten(self):
|
|
1502
|
+
for side, box in self.boxes.items():
|
|
1503
|
+
box.setValue(float(self.least.get(side, 0.0)))
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
class DataMarginsDialog(_MarginsDialog):
|
|
1507
|
+
"""The data's margins inside the axes box: the share of the axis left
|
|
1508
|
+
empty beyond the data on each side (the fit-margin arrows'). Two on one
|
|
1509
|
+
axis must leave the data some room."""
|
|
1510
|
+
|
|
1511
|
+
TITLE = "Data margins"
|
|
1512
|
+
|
|
1513
|
+
def _shape(self, box, side):
|
|
1514
|
+
box.setDecimals(3)
|
|
1515
|
+
box.setRange(0.0, 0.9)
|
|
1516
|
+
box.setSingleStep(0.01)
|
|
1517
|
+
box.setToolTip("The share of the axis left empty beyond the data: "
|
|
1518
|
+
"0.1 is 10 %.")
|
|
1519
|
+
|
|
1520
|
+
def _check(self):
|
|
1521
|
+
values = self.values()
|
|
1522
|
+
fine = (values["left"] + values["right"] < style.FIT_MOST
|
|
1523
|
+
and values["bottom"] + values["top"] < style.FIT_MOST)
|
|
1524
|
+
for pair in (("left", "right"), ("bottom", "top")):
|
|
1525
|
+
bad = sum(values[s] for s in pair) >= style.FIT_MOST
|
|
1526
|
+
for side in pair:
|
|
1527
|
+
self.boxes[side].setStyleSheet(
|
|
1528
|
+
"border: 1px solid #d04040;" if bad else "")
|
|
1529
|
+
self.buttons.button(QDialogButtonBox.Ok).setEnabled(fine)
|
|
1530
|
+
self.note.setText("The share of each axis left empty beyond the "
|
|
1531
|
+
"data: left 0.1 is the first tenth of the x "
|
|
1532
|
+
"axis." if fine else
|
|
1533
|
+
"Two margins of one axis leave the data no room.")
|
|
1534
|
+
return fine
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
class MolarMassPrompt(QDialog):
|
|
1538
|
+
""""Set the molar mass" (F3, a scan's menu): the number, and the
|
|
1539
|
+
calculator beside it."""
|
|
1540
|
+
|
|
1541
|
+
def __init__(self, samples, parent=None):
|
|
1542
|
+
QDialog.__init__(self, parent)
|
|
1543
|
+
self.samples = list(samples)
|
|
1544
|
+
self.setWindowTitle("Molar mass")
|
|
1545
|
+
layout = QVBoxLayout(self)
|
|
1546
|
+
form = QFormLayout()
|
|
1547
|
+
layout.addLayout(form)
|
|
1548
|
+
form.addRow("For", QLabel(", ".join(s.name for s in self.samples)))
|
|
1549
|
+
self.molar = NumberBox(self)
|
|
1550
|
+
self.molar.setDecimals(4)
|
|
1551
|
+
self.molar.setRange(0.0, 1e7)
|
|
1552
|
+
self.molar.setSpecialValueText("not given")
|
|
1553
|
+
self.molar.setSuffix(" g/mol")
|
|
1554
|
+
self.molar.setValue(float(self.samples[0].molar_mass or 0.0))
|
|
1555
|
+
self.molar.setToolTip("Needed for W/mol and kJ/mol. Never assumed.")
|
|
1556
|
+
form.addRow("Molar mass", molar_row(self, self.molar,
|
|
1557
|
+
self.samples[0]))
|
|
1558
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Ok
|
|
1559
|
+
| QDialogButtonBox.Cancel)
|
|
1560
|
+
buttons.accepted.connect(self.accept)
|
|
1561
|
+
buttons.rejected.connect(self.reject)
|
|
1562
|
+
layout.addWidget(buttons)
|
|
1563
|
+
|
|
1564
|
+
def value(self):
|
|
1565
|
+
value = float(self.molar.value())
|
|
1566
|
+
return value if value > 0 else None
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
class SampleSettings(_LiveDialog):
|
|
1570
|
+
"""The substance: its molar mass, and which way its arrays point."""
|
|
1571
|
+
|
|
1572
|
+
FIELDS = ("molar_mass", "exo")
|
|
1573
|
+
|
|
1574
|
+
def __init__(self, parent, sample, on_change=None):
|
|
1575
|
+
_LiveDialog.__init__(self, parent, sample, on_change)
|
|
1576
|
+
self.setWindowTitle("Sample: {}".format(sample.name))
|
|
1577
|
+
layout = QVBoxLayout(self)
|
|
1578
|
+
form = QFormLayout()
|
|
1579
|
+
layout.addLayout(form)
|
|
1580
|
+
|
|
1581
|
+
form.addRow("File", QLabel(sample.path))
|
|
1582
|
+
form.addRow("Instrument", QLabel(sample.instrument or "unknown"))
|
|
1583
|
+
form.addRow("Sample mass", QLabel(
|
|
1584
|
+
"{:g} mg".format(sample.mass_g * 1000.0) if sample.mass_g
|
|
1585
|
+
else "not in the file"))
|
|
1586
|
+
|
|
1587
|
+
self.molar = NumberBox()
|
|
1588
|
+
self.molar.setDecimals(4)
|
|
1589
|
+
self.molar.setRange(0.0, 1e7)
|
|
1590
|
+
self.molar.setSpecialValueText("not given")
|
|
1591
|
+
self.molar.setSuffix(" g/mol")
|
|
1592
|
+
self.molar.setValue(float(sample.molar_mass or 0.0))
|
|
1593
|
+
self.molar.setToolTip("Needed for W/mol and kJ/mol. Never "
|
|
1594
|
+
"assumed. Every scan of the file has it.")
|
|
1595
|
+
form.addRow("Molar mass", molar_row(self, self.molar, sample))
|
|
1596
|
+
|
|
1597
|
+
self.exo = QComboBox()
|
|
1598
|
+
for value in (units.EXO_DOWN, units.EXO_UP):
|
|
1599
|
+
self.exo.addItem("exotherms point {}".format(value), value)
|
|
1600
|
+
self.exo.setCurrentIndex(0 if sample.exo == units.EXO_DOWN else 1)
|
|
1601
|
+
form.addRow("In this file", self.exo)
|
|
1602
|
+
|
|
1603
|
+
note = QLabel("Direction from the file: {}. Changing it flips "
|
|
1604
|
+
"this sample's curves.".format(sample.exo_source))
|
|
1605
|
+
note.setWordWrap(True)
|
|
1606
|
+
note.setStyleSheet("color: #9a9a9a;")
|
|
1607
|
+
layout.addWidget(note)
|
|
1608
|
+
|
|
1609
|
+
buttons = self._buttons()
|
|
1610
|
+
layout.addWidget(buttons)
|
|
1611
|
+
|
|
1612
|
+
self.molar.valueChanged.connect(self._apply)
|
|
1613
|
+
self.exo.currentIndexChanged.connect(self._apply)
|
|
1614
|
+
|
|
1615
|
+
def _apply(self, *_args):
|
|
1616
|
+
sample = self.obj
|
|
1617
|
+
sample.molar_mass = (float(self.molar.value())
|
|
1618
|
+
if self.molar.value() > 0 else None)
|
|
1619
|
+
sample.exo = self.exo.currentData()
|
|
1620
|
+
for scan in sample.scans:
|
|
1621
|
+
scan._cache_key = None
|
|
1622
|
+
self._live()
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def axis_title(axis, part=""):
|
|
1626
|
+
""""X axis", "Y axis numbers", "Mass axis caption": the windows of an
|
|
1627
|
+
axis, the mass axis by its name rather than "Y2"."""
|
|
1628
|
+
name = ("Mass axis" if axis.which == "y2"
|
|
1629
|
+
else "{} axis".format(axis.which.upper()))
|
|
1630
|
+
return "{} {}".format(name, part) if part else name
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
class _SideRow(object):
|
|
1634
|
+
""""Side" in every window of an axis - its spine's, its numbers' and
|
|
1635
|
+
its caption's, so a double-click on the y caption offers one too. It
|
|
1636
|
+
shows the side the axis is DRAWN on and puts it on the one chosen,
|
|
1637
|
+
through the window: the mass axis takes the heat
|
|
1638
|
+
flow axis's side, so choosing its side changes that one."""
|
|
1639
|
+
|
|
1640
|
+
def _side_row(self, form):
|
|
1641
|
+
axis = self.obj
|
|
1642
|
+
self.side = QComboBox()
|
|
1643
|
+
for side in (("bottom", "top") if axis.which == "x"
|
|
1644
|
+
else ("left", "right")):
|
|
1645
|
+
self.side.addItem(side, side)
|
|
1646
|
+
plot = _plot_of(self)
|
|
1647
|
+
drawn = plot.axis_side(axis) if plot is not None else axis.side
|
|
1648
|
+
self.side.setCurrentIndex(max(0, self.side.findData(drawn)))
|
|
1649
|
+
self.side.setToolTip("Which side of the plot the axis is on: its "
|
|
1650
|
+
"line, numbers and caption.")
|
|
1651
|
+
form.addRow("Side", self.side)
|
|
1652
|
+
self.side.currentIndexChanged.connect(lambda _i: self._side_chosen())
|
|
1653
|
+
|
|
1654
|
+
def _side_chosen(self):
|
|
1655
|
+
window = _window_of(self)
|
|
1656
|
+
if window is not None:
|
|
1657
|
+
window.set_axis_side(self.obj, self.side.currentData())
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
class CaptionSettings(_SideRow, _LiveDialog):
|
|
1661
|
+
"""An axis CAPTION: its words and its size, and nothing else.
|
|
1662
|
+
|
|
1663
|
+
Separate from the axis's own settings on purpose: the tick settings
|
|
1664
|
+
should not be what a double-click on the label gives you - the
|
|
1665
|
+
label is a piece of text, the spine is the axis. Double-clicking the
|
|
1666
|
+
spine opens `AxisSettings`; this is what the caption opens.
|
|
1667
|
+
"""
|
|
1668
|
+
|
|
1669
|
+
FIELDS = ("label", "label_size", "label_along", "label_gap", "visible")
|
|
1670
|
+
INDIVIDUAL = ("label", "label_along")
|
|
1671
|
+
GROUP_DISABLED = ("label",)
|
|
1672
|
+
|
|
1673
|
+
def __init__(self, parent, axis, doc, on_change=None):
|
|
1674
|
+
_LiveDialog.__init__(self, parent, axis, on_change)
|
|
1675
|
+
self.doc = doc
|
|
1676
|
+
self.setWindowTitle(axis_title(axis, "caption"))
|
|
1677
|
+
layout = QVBoxLayout(self)
|
|
1678
|
+
form = QFormLayout()
|
|
1679
|
+
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
1680
|
+
layout.addLayout(form)
|
|
1681
|
+
|
|
1682
|
+
self.shown = QCheckBox("Show")
|
|
1683
|
+
self.shown.setChecked(bool(axis.visible))
|
|
1684
|
+
self.shown.setToolTip("Draw the caption.")
|
|
1685
|
+
form.addRow("", self.shown)
|
|
1686
|
+
|
|
1687
|
+
self.label = QLineEdit(axis.label or "")
|
|
1688
|
+
self.label.setPlaceholderText(axis.caption(doc))
|
|
1689
|
+
form.addRow("Text", self.label)
|
|
1690
|
+
|
|
1691
|
+
self.text_size = _style_number(self, axis, "label_size")
|
|
1692
|
+
form.addRow("Size", self.text_size)
|
|
1693
|
+
|
|
1694
|
+
self.gap = _style_number(self, axis, "label_gap")
|
|
1695
|
+
self.gap.setToolTip("Space between the numbers and the caption. "
|
|
1696
|
+
"Dragging sets it too.")
|
|
1697
|
+
form.addRow("Distance", self.gap)
|
|
1698
|
+
self._side_row(form)
|
|
1699
|
+
|
|
1700
|
+
note = QLabel("*T* italic, _{g} subscript, \\Delta Greek, LaTeX "
|
|
1701
|
+
"between $...$. Drag the caption to move it.")
|
|
1702
|
+
note.setWordWrap(True)
|
|
1703
|
+
note.setStyleSheet("color: #9a9a9a;")
|
|
1704
|
+
layout.addWidget(note)
|
|
1705
|
+
|
|
1706
|
+
buttons = self._buttons()
|
|
1707
|
+
layout.addWidget(buttons)
|
|
1708
|
+
|
|
1709
|
+
self.label.textChanged.connect(self._apply)
|
|
1710
|
+
self.text_size.changed.connect(self._apply)
|
|
1711
|
+
self.gap.changed.connect(self._apply)
|
|
1712
|
+
self.shown.toggled.connect(self._apply)
|
|
1713
|
+
|
|
1714
|
+
def _apply(self, *_args):
|
|
1715
|
+
self.obj.label = self.label.text().strip() or None
|
|
1716
|
+
self.obj.label_size = self.text_size.value()
|
|
1717
|
+
self.obj.label_gap = self.gap.value()
|
|
1718
|
+
self.obj.visible = bool(self.shown.isChecked())
|
|
1719
|
+
self._live()
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def axis_unit(doc, axis):
|
|
1723
|
+
"""The unit an axis's numbers are in, for its range boxes."""
|
|
1724
|
+
if doc is None:
|
|
1725
|
+
return ""
|
|
1726
|
+
if axis.which == "x":
|
|
1727
|
+
if doc.x_axis != units_module.AXIS_TEMPERATURE:
|
|
1728
|
+
return "min"
|
|
1729
|
+
return units.TEMPERATURE_LABEL.get(doc.x_unit, doc.x_unit)
|
|
1730
|
+
if axis.which == "y2":
|
|
1731
|
+
return doc.weight_unit
|
|
1732
|
+
return doc.y_axis_unit()
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
class _RangeRows(object):
|
|
1736
|
+
"""The Range and Locked rows of an axis's settings."""
|
|
1737
|
+
|
|
1738
|
+
def _show_range(self):
|
|
1739
|
+
plot = _plot_of(self)
|
|
1740
|
+
shown = plot is not None and self.obj.which in plot.shown_view_axes()
|
|
1741
|
+
for widget in (self.low, self.high, self.locked):
|
|
1742
|
+
widget.setEnabled(shown)
|
|
1743
|
+
widget.blockSignals(True)
|
|
1744
|
+
if shown:
|
|
1745
|
+
lo, hi = plot.view_of(self.obj.which)
|
|
1746
|
+
self.low.setValue(float(lo))
|
|
1747
|
+
self.high.setValue(float(hi))
|
|
1748
|
+
self.locked.setChecked(plot.lock_of(self.obj.which) is not None)
|
|
1749
|
+
for widget in (self.low, self.high, self.locked):
|
|
1750
|
+
widget.blockSignals(False)
|
|
1751
|
+
|
|
1752
|
+
def _typed_range(self):
|
|
1753
|
+
window = _window_of(self)
|
|
1754
|
+
lo, hi = float(self.low.value()), float(self.high.value())
|
|
1755
|
+
ok = hi > lo and window is not None and window.set_axis_range(
|
|
1756
|
+
self.obj, lo, hi)
|
|
1757
|
+
for box in (self.low, self.high):
|
|
1758
|
+
box.setStyleSheet("" if ok else "border: 1px solid #d04040;")
|
|
1759
|
+
if ok:
|
|
1760
|
+
self._show_range()
|
|
1761
|
+
|
|
1762
|
+
def _lock_toggled(self, on):
|
|
1763
|
+
window = _window_of(self)
|
|
1764
|
+
if window is None:
|
|
1765
|
+
return
|
|
1766
|
+
if on:
|
|
1767
|
+
window.lock_axis(self.obj, float(self.low.value()),
|
|
1768
|
+
float(self.high.value()))
|
|
1769
|
+
else:
|
|
1770
|
+
window.lock_axis(self.obj, False)
|
|
1771
|
+
self._show_range()
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
class AxisSettings(_SideRow, _RangeRows, _LiveDialog):
|
|
1775
|
+
"""The SPINE: its side, its ticks and their steps, the line opposite it,
|
|
1776
|
+
and the grid. Opened by double-clicking the axis line; its numbers
|
|
1777
|
+
(`NumberSettings`) and its caption (`CaptionSettings`) have their own
|
|
1778
|
+
windows.
|
|
1779
|
+
|
|
1780
|
+
The defaults are Origin's closed frame on the DSC_Plotter template's
|
|
1781
|
+
ticks: inward, minor ticks, the opposite line with ticks and no numbers,
|
|
1782
|
+
no grid.
|
|
1783
|
+
"""
|
|
1784
|
+
|
|
1785
|
+
FIELDS = ("ticks_inward", "tick_length", "minor_ticks",
|
|
1786
|
+
"minor_count", "minor_length", "major_step", "mirror",
|
|
1787
|
+
"mirror_ticks", "show_grid")
|
|
1788
|
+
|
|
1789
|
+
def __init__(self, parent, axis, doc, on_change=None):
|
|
1790
|
+
_LiveDialog.__init__(self, parent, axis, on_change)
|
|
1791
|
+
self.doc = doc
|
|
1792
|
+
self.setWindowTitle(axis_title(axis))
|
|
1793
|
+
layout = QVBoxLayout(self)
|
|
1794
|
+
form = QFormLayout()
|
|
1795
|
+
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
1796
|
+
layout.addLayout(form)
|
|
1797
|
+
|
|
1798
|
+
# The RANGE, its minimum and maximum: what the axis shows now,
|
|
1799
|
+
# typed back as one view step; locked, F returns to it. Through the
|
|
1800
|
+
# window, never this dialog's snapshot.
|
|
1801
|
+
self.low = NumberBox()
|
|
1802
|
+
self.high = NumberBox()
|
|
1803
|
+
unit = axis_unit(doc, axis)
|
|
1804
|
+
for box in (self.low, self.high):
|
|
1805
|
+
box.setDecimals(4)
|
|
1806
|
+
box.setRange(-1e9, 1e9)
|
|
1807
|
+
if unit:
|
|
1808
|
+
box.setSuffix(" " + unit)
|
|
1809
|
+
self.low.setToolTip("The axis's minimum. A sum works: 50-10.")
|
|
1810
|
+
self.high.setToolTip("The axis's maximum.")
|
|
1811
|
+
range_row = QWidget(self)
|
|
1812
|
+
range_line = QHBoxLayout(range_row)
|
|
1813
|
+
range_line.setContentsMargins(0, 0, 0, 0)
|
|
1814
|
+
range_line.addWidget(self.low, 1)
|
|
1815
|
+
range_line.addWidget(QLabel("to"), 0)
|
|
1816
|
+
range_line.addWidget(self.high, 1)
|
|
1817
|
+
form.addRow("Range", range_row)
|
|
1818
|
+
self.locked = QCheckBox("Locked: F returns to this range")
|
|
1819
|
+
self.locked.setToolTip("Unticked, F fits the data.")
|
|
1820
|
+
form.addRow("", self.locked)
|
|
1821
|
+
self._show_range()
|
|
1822
|
+
self.low.valueChanged.connect(lambda _v: self._typed_range())
|
|
1823
|
+
self.high.valueChanged.connect(lambda _v: self._typed_range())
|
|
1824
|
+
self.locked.toggled.connect(self._lock_toggled)
|
|
1825
|
+
|
|
1826
|
+
self._side_row(form)
|
|
1827
|
+
|
|
1828
|
+
self.inward = QCheckBox("Ticks point inward")
|
|
1829
|
+
self.inward.setChecked(bool(axis.ticks_inward))
|
|
1830
|
+
self.inward.setToolTip("Into the plot, as the template draws them.")
|
|
1831
|
+
form.addRow("", self.inward)
|
|
1832
|
+
|
|
1833
|
+
self.step = NumberBox()
|
|
1834
|
+
self.step.setDecimals(4)
|
|
1835
|
+
self.step.setRange(0.0, 1e6)
|
|
1836
|
+
self.step.setToolTip("Distance between numbered ticks, in the "
|
|
1837
|
+
"axis unit.")
|
|
1838
|
+
self.step_auto = QCheckBox("Automatic")
|
|
1839
|
+
self.step_auto.setToolTip("A round step that fits the range.")
|
|
1840
|
+
step_row = QWidget(self)
|
|
1841
|
+
step_line = QHBoxLayout(step_row)
|
|
1842
|
+
step_line.setContentsMargins(0, 0, 0, 0)
|
|
1843
|
+
step_line.addWidget(self.step, 1)
|
|
1844
|
+
step_line.addWidget(self.step_auto, 0)
|
|
1845
|
+
form.addRow("Major step", step_row)
|
|
1846
|
+
|
|
1847
|
+
self.tick_length = NumberBox()
|
|
1848
|
+
self.minor_length = NumberBox()
|
|
1849
|
+
for box in (self.tick_length, self.minor_length):
|
|
1850
|
+
box.setDecimals(1)
|
|
1851
|
+
box.setRange(0.0, 60.0)
|
|
1852
|
+
box.setSuffix(" px")
|
|
1853
|
+
self.tick_length.setToolTip("Length of the numbered ticks.")
|
|
1854
|
+
self.minor_length.setToolTip("Length of the minor ticks.")
|
|
1855
|
+
form.addRow("Major length", self.tick_length)
|
|
1856
|
+
|
|
1857
|
+
self.minor = QCheckBox("Minor ticks")
|
|
1858
|
+
self.minor.setToolTip("Unnumbered ticks between the numbered ones.")
|
|
1859
|
+
form.addRow("", self.minor)
|
|
1860
|
+
self.minor_count = WholeBox()
|
|
1861
|
+
self.minor_count.setRange(1, 20)
|
|
1862
|
+
self.minor_count.setToolTip("Minor intervals per major one: 5 puts "
|
|
1863
|
+
"four ticks between two numbers.")
|
|
1864
|
+
form.addRow("Minor intervals", self.minor_count)
|
|
1865
|
+
form.addRow("Minor length", self.minor_length)
|
|
1866
|
+
|
|
1867
|
+
self.mirror = QCheckBox("Line on the opposite side")
|
|
1868
|
+
self.mirror.setToolTip("Close the frame of the plot.")
|
|
1869
|
+
form.addRow("", self.mirror)
|
|
1870
|
+
self.mirror_ticks = QCheckBox("Ticks on it (no numbers)")
|
|
1871
|
+
self.mirror_ticks.setToolTip("Origin's style: the opposite line "
|
|
1872
|
+
"ticked like this one.")
|
|
1873
|
+
form.addRow("", self.mirror_ticks)
|
|
1874
|
+
if axis.which == "y2":
|
|
1875
|
+
# The mass axis alone closes the box like any y axis; with the
|
|
1876
|
+
# heat flow's drawn too, the far side is that axis's own.
|
|
1877
|
+
self.mirror.setToolTip("While it is the only y axis: the line "
|
|
1878
|
+
"on the other side, closing the box.")
|
|
1879
|
+
|
|
1880
|
+
self.grid = QCheckBox("Grid lines")
|
|
1881
|
+
self.grid.setToolTip("Lines across the plot at the numbered ticks.")
|
|
1882
|
+
form.addRow("", self.grid)
|
|
1883
|
+
|
|
1884
|
+
note = QLabel("The numbers and the caption have their own settings: "
|
|
1885
|
+
"double-click them.")
|
|
1886
|
+
note.setWordWrap(True)
|
|
1887
|
+
note.setStyleSheet("color: #9a9a9a;")
|
|
1888
|
+
layout.addWidget(note)
|
|
1889
|
+
|
|
1890
|
+
buttons = self._buttons()
|
|
1891
|
+
layout.addWidget(buttons)
|
|
1892
|
+
self._show()
|
|
1893
|
+
|
|
1894
|
+
for check in (self.inward, self.minor, self.mirror,
|
|
1895
|
+
self.mirror_ticks, self.grid, self.step_auto):
|
|
1896
|
+
check.toggled.connect(self._apply)
|
|
1897
|
+
for box in (self.step, self.tick_length, self.minor_length):
|
|
1898
|
+
box.valueChanged.connect(self._apply)
|
|
1899
|
+
self.minor_count.valueChanged.connect(self._apply)
|
|
1900
|
+
|
|
1901
|
+
def _step_shown(self):
|
|
1902
|
+
"""The step as it is drawn, for the box while it is automatic."""
|
|
1903
|
+
plot = _plot_of(self)
|
|
1904
|
+
axis = self.obj
|
|
1905
|
+
if plot is None:
|
|
1906
|
+
return 0.0
|
|
1907
|
+
# Each axis its OWN range: the weight axis showed the heat flow's
|
|
1908
|
+
# step (0.1 W/g where it draws 5 %), and unticking Automatic
|
|
1909
|
+
# stored it.
|
|
1910
|
+
lo, hi = {"x": plot.view_x, "y2": plot.view_y2}.get(
|
|
1911
|
+
axis.which, plot.view_y)()
|
|
1912
|
+
return float(plot.tick_step(axis, lo, hi))
|
|
1913
|
+
|
|
1914
|
+
def _show(self):
|
|
1915
|
+
axis = self.obj
|
|
1916
|
+
widgets = (self.inward, self.step, self.step_auto, self.tick_length,
|
|
1917
|
+
self.minor, self.minor_count, self.minor_length,
|
|
1918
|
+
self.mirror, self.mirror_ticks, self.grid)
|
|
1919
|
+
for widget in widgets:
|
|
1920
|
+
widget.blockSignals(True)
|
|
1921
|
+
self.inward.setChecked(bool(axis.ticks_inward))
|
|
1922
|
+
self.step_auto.setChecked(axis.major_step is None)
|
|
1923
|
+
self.step.setValue(float(axis.major_step or self._step_shown()))
|
|
1924
|
+
self.step.setEnabled(axis.major_step is not None)
|
|
1925
|
+
self.tick_length.setValue(float(axis.tick_length))
|
|
1926
|
+
self.minor.setChecked(bool(axis.minor_ticks))
|
|
1927
|
+
self.minor_count.setValue(int(axis.minor_count))
|
|
1928
|
+
self.minor_length.setValue(float(axis.minor_length))
|
|
1929
|
+
self.minor_count.setEnabled(bool(axis.minor_ticks))
|
|
1930
|
+
self.minor_length.setEnabled(bool(axis.minor_ticks))
|
|
1931
|
+
self.mirror.setChecked(bool(axis.mirror))
|
|
1932
|
+
self.mirror_ticks.setChecked(bool(axis.mirror_ticks))
|
|
1933
|
+
self.mirror_ticks.setEnabled(bool(axis.mirror))
|
|
1934
|
+
self.grid.setChecked(bool(axis.show_grid))
|
|
1935
|
+
for widget in widgets:
|
|
1936
|
+
widget.blockSignals(False)
|
|
1937
|
+
|
|
1938
|
+
def _apply(self, *_args):
|
|
1939
|
+
axis = self.obj
|
|
1940
|
+
axis.ticks_inward = bool(self.inward.isChecked())
|
|
1941
|
+
if self.step_auto.isChecked():
|
|
1942
|
+
axis.major_step = None
|
|
1943
|
+
else:
|
|
1944
|
+
axis.major_step = float(self.step.value()) or None
|
|
1945
|
+
axis.tick_length = float(self.tick_length.value())
|
|
1946
|
+
axis.minor_ticks = bool(self.minor.isChecked())
|
|
1947
|
+
axis.minor_count = int(self.minor_count.value())
|
|
1948
|
+
axis.minor_length = float(self.minor_length.value())
|
|
1949
|
+
axis.mirror = bool(self.mirror.isChecked())
|
|
1950
|
+
axis.mirror_ticks = bool(self.mirror_ticks.isChecked())
|
|
1951
|
+
axis.show_grid = bool(self.grid.isChecked())
|
|
1952
|
+
self._live()
|
|
1953
|
+
self._show()
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
class NumberSettings(_SideRow, _LiveDialog):
|
|
1957
|
+
"""An axis's NUMBERS: shown or not, their size, their format, and
|
|
1958
|
+
which of them are left out. Opened by double-clicking them; the spine
|
|
1959
|
+
and the caption have their own."""
|
|
1960
|
+
|
|
1961
|
+
FIELDS = ("show_numbers", "tick_size", "number_format",
|
|
1962
|
+
"hidden_numbers", "hidden_context")
|
|
1963
|
+
# Values in one axis's unit: never copied to another axis.
|
|
1964
|
+
INDIVIDUAL = ("hidden_numbers", "hidden_context")
|
|
1965
|
+
GROUP_DISABLED = ("hidden_numbers",)
|
|
1966
|
+
|
|
1967
|
+
def __init__(self, parent, axis, doc, on_change=None):
|
|
1968
|
+
_LiveDialog.__init__(self, parent, axis, on_change)
|
|
1969
|
+
self.doc = doc
|
|
1970
|
+
self.setWindowTitle(axis_title(axis, "numbers"))
|
|
1971
|
+
layout = QVBoxLayout(self)
|
|
1972
|
+
form = QFormLayout()
|
|
1973
|
+
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
1974
|
+
layout.addLayout(form)
|
|
1975
|
+
|
|
1976
|
+
self.shown = QCheckBox("Show")
|
|
1977
|
+
self.shown.setChecked(bool(axis.show_numbers))
|
|
1978
|
+
self.shown.setToolTip("Draw the numbers; the ticks stay.")
|
|
1979
|
+
form.addRow("", self.shown)
|
|
1980
|
+
|
|
1981
|
+
self.tick_size = _style_number(self, axis, "tick_size")
|
|
1982
|
+
form.addRow("Size", self.tick_size)
|
|
1983
|
+
|
|
1984
|
+
# Not house style: an x axis in degrees and a y axis in W/g want
|
|
1985
|
+
# different digits, so "automatic" is the only shared default.
|
|
1986
|
+
self.number_format = StyleText(axis.number_format, lambda: "",
|
|
1987
|
+
parent=self)
|
|
1988
|
+
# The general tooltip speaks of units, which an axis's numbers
|
|
1989
|
+
# cannot carry (the unit is in the caption).
|
|
1990
|
+
self.number_format.edit.setToolTip(
|
|
1991
|
+
"How every number on this axis is written. %.0f: whole "
|
|
1992
|
+
"numbers (50). %.1f: one decimal (50.0). %.2f: two. %.2g: two "
|
|
1993
|
+
"significant figures (0.51, 1.2, 15). Empty: as few digits as "
|
|
1994
|
+
"the spacing of the ticks needs.")
|
|
1995
|
+
form.addRow("Format", self.number_format)
|
|
1996
|
+
#: What the axis writes now, with this format and without the
|
|
1997
|
+
#: hidden ones: the Format box's effect, seen before looking at
|
|
1998
|
+
#: the plot.
|
|
1999
|
+
self.written = QLabel(self)
|
|
2000
|
+
self.written.setWordWrap(True)
|
|
2001
|
+
form.addRow("Written", self.written)
|
|
2002
|
+
|
|
2003
|
+
self.hidden_numbers = QLineEdit(self)
|
|
2004
|
+
self.hidden_numbers.setPlaceholderText("none")
|
|
2005
|
+
self.hidden_numbers.setToolTip(
|
|
2006
|
+
"Numbers NOT written on this axis - their ticks stay. Type "
|
|
2007
|
+
"them as the axis writes them, separated by ';', ', ' or a "
|
|
2008
|
+
"space: \"50\" leaves the 50 at the corner of the box out. "
|
|
2009
|
+
"Right-click a number on the plot to hide or show that one.")
|
|
2010
|
+
self._show_hidden()
|
|
2011
|
+
form.addRow("Hidden", self.hidden_numbers)
|
|
2012
|
+
self._side_row(form)
|
|
2013
|
+
|
|
2014
|
+
buttons = self._buttons()
|
|
2015
|
+
layout.addWidget(buttons)
|
|
2016
|
+
|
|
2017
|
+
self.shown.toggled.connect(self._apply)
|
|
2018
|
+
self.tick_size.changed.connect(self._apply)
|
|
2019
|
+
self.number_format.changed.connect(self._apply)
|
|
2020
|
+
self.hidden_numbers.textEdited.connect(self._hidden_typed)
|
|
2021
|
+
self._show_written()
|
|
2022
|
+
|
|
2023
|
+
def _context(self):
|
|
2024
|
+
plot = _plot_of(self)
|
|
2025
|
+
return (plot.axis_context(self.obj.which) if plot is not None
|
|
2026
|
+
else None)
|
|
2027
|
+
|
|
2028
|
+
def _show_hidden(self):
|
|
2029
|
+
"""The hidden numbers that apply in the axis's unit now."""
|
|
2030
|
+
axis = self.obj
|
|
2031
|
+
values = (axis.hidden_numbers
|
|
2032
|
+
if list(axis.hidden_context or []) == (self._context()
|
|
2033
|
+
or [])
|
|
2034
|
+
else [])
|
|
2035
|
+
self.hidden_numbers.setText("; ".join(
|
|
2036
|
+
"{:g}".format(round(float(v), 10) + 0.0) for v in values))
|
|
2037
|
+
|
|
2038
|
+
def _show_written(self):
|
|
2039
|
+
plot = _plot_of(self)
|
|
2040
|
+
if plot is None or not self.obj.show_numbers:
|
|
2041
|
+
self.written.setText("(none)" if not self.obj.show_numbers
|
|
2042
|
+
else "")
|
|
2043
|
+
return
|
|
2044
|
+
texts = [text for _v, _at, text in plot.numbered_ticks(self.obj)]
|
|
2045
|
+
self.written.setText(", ".join(texts) or "(none)")
|
|
2046
|
+
|
|
2047
|
+
def _apply(self, *_args):
|
|
2048
|
+
axis = self.obj
|
|
2049
|
+
axis.show_numbers = bool(self.shown.isChecked())
|
|
2050
|
+
axis.tick_size = self.tick_size.value()
|
|
2051
|
+
axis.number_format = self.number_format.value()
|
|
2052
|
+
self._live()
|
|
2053
|
+
self._show_written()
|
|
2054
|
+
|
|
2055
|
+
def _hidden_typed(self, text):
|
|
2056
|
+
"""Only when typed here: numbers hidden in another unit are kept
|
|
2057
|
+
while the rest of the window is used."""
|
|
2058
|
+
typed = numbers.values(text)
|
|
2059
|
+
if typed is None:
|
|
2060
|
+
self.hidden_numbers.setStyleSheet("border: 1px solid #d04040;")
|
|
2061
|
+
return
|
|
2062
|
+
self.hidden_numbers.setStyleSheet("")
|
|
2063
|
+
axis = self.obj
|
|
2064
|
+
# Replaced, never changed in place (the snapshot).
|
|
2065
|
+
axis.hidden_numbers = sorted(typed)
|
|
2066
|
+
axis.hidden_context = self._context() if typed else None
|
|
2067
|
+
self._live()
|
|
2068
|
+
self._show_written()
|
|
2069
|
+
|
|
2070
|
+
|
|
2071
|
+
class FigureSettings(_LiveDialog):
|
|
2072
|
+
"""This figure's size and the place of its axes box - saved with it.
|
|
2073
|
+
|
|
2074
|
+
Two session files (first up-scans, second up-scans) exported
|
|
2075
|
+
with the same settings must come out the same size, with their axes
|
|
2076
|
+
boxes the same size and in the same place, so that they sit side by side
|
|
2077
|
+
in Word without fiddling. In "an exact size" the MARGINS decide the axes
|
|
2078
|
+
box; the numbers and captions have to fit inside them, and this says
|
|
2079
|
+
when they do not.
|
|
2080
|
+
"""
|
|
2081
|
+
|
|
2082
|
+
FIELDS = figure_module.FigureLayout.FIELDS
|
|
2083
|
+
|
|
2084
|
+
def __init__(self, parent, layout_obj, plot=None, on_change=None):
|
|
2085
|
+
_LiveDialog.__init__(self, parent, layout_obj, on_change)
|
|
2086
|
+
self.plot = plot
|
|
2087
|
+
self.setWindowTitle("Figure size and margins")
|
|
2088
|
+
layout = QVBoxLayout(self)
|
|
2089
|
+
form = QFormLayout()
|
|
2090
|
+
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
2091
|
+
layout.addLayout(form)
|
|
2092
|
+
|
|
2093
|
+
self.mode = QComboBox()
|
|
2094
|
+
for mode in figure_module.MODES:
|
|
2095
|
+
self.mode.addItem(figure_module.MODE_TITLES[mode], mode)
|
|
2096
|
+
self.mode.setCurrentIndex(max(0, self.mode.findData(layout_obj.mode)))
|
|
2097
|
+
form.addRow("The figure has", self.mode)
|
|
2098
|
+
|
|
2099
|
+
self.unit = QComboBox()
|
|
2100
|
+
for unit, title in ((figure_module.UNIT_CM, "centimetres"),
|
|
2101
|
+
(figure_module.UNIT_IN, "inches")):
|
|
2102
|
+
self.unit.addItem(title, unit)
|
|
2103
|
+
self.unit.setCurrentIndex(max(0, self.unit.findData(layout_obj.unit)))
|
|
2104
|
+
form.addRow("Unit", self.unit)
|
|
2105
|
+
|
|
2106
|
+
def pair(first, second, between="x"):
|
|
2107
|
+
row = QWidget(self)
|
|
2108
|
+
line = QHBoxLayout(row)
|
|
2109
|
+
line.setContentsMargins(0, 0, 0, 0)
|
|
2110
|
+
line.addWidget(first, 1)
|
|
2111
|
+
line.addWidget(QLabel(between))
|
|
2112
|
+
line.addWidget(second, 1)
|
|
2113
|
+
return row
|
|
2114
|
+
|
|
2115
|
+
def length():
|
|
2116
|
+
box = NumberBox()
|
|
2117
|
+
box.setDecimals(2)
|
|
2118
|
+
box.setRange(0.0, 200.0)
|
|
2119
|
+
box.setSingleStep(0.1)
|
|
2120
|
+
return box
|
|
2121
|
+
|
|
2122
|
+
self.fig_w, self.fig_h = length(), length()
|
|
2123
|
+
form.addRow("Size (width x height)", pair(self.fig_w, self.fig_h))
|
|
2124
|
+
self.margin_l, self.margin_r = length(), length()
|
|
2125
|
+
self.margin_t, self.margin_b = length(), length()
|
|
2126
|
+
form.addRow("Margins left, right", pair(self.margin_l, self.margin_r,
|
|
2127
|
+
","))
|
|
2128
|
+
form.addRow("Margins top, bottom", pair(self.margin_t, self.margin_b,
|
|
2129
|
+
","))
|
|
2130
|
+
self.axes_w, self.axes_h = length(), length()
|
|
2131
|
+
self.axes_w.setToolTip("The axes box; the figure grows around "
|
|
2132
|
+
"it.")
|
|
2133
|
+
self.axes_h.setToolTip(self.axes_w.toolTip())
|
|
2134
|
+
form.addRow("Axes box", pair(self.axes_w, self.axes_h))
|
|
2135
|
+
|
|
2136
|
+
self.aspect_w, self.aspect_h = NumberBox(), NumberBox()
|
|
2137
|
+
for box in (self.aspect_w, self.aspect_h):
|
|
2138
|
+
box.setDecimals(2)
|
|
2139
|
+
box.setRange(0.1, 100.0)
|
|
2140
|
+
form.addRow("Aspect ratio", pair(self.aspect_w, self.aspect_h, ":"))
|
|
2141
|
+
|
|
2142
|
+
self.dpi = WholeBox()
|
|
2143
|
+
self.dpi.setRange(72, 2400)
|
|
2144
|
+
self.dpi.setSingleStep(50)
|
|
2145
|
+
self.dpi.setSuffix(" dpi")
|
|
2146
|
+
self.dpi.setToolTip("Pixels per inch of a PNG. SVG is exact.")
|
|
2147
|
+
form.addRow("PNG resolution", self.dpi)
|
|
2148
|
+
|
|
2149
|
+
self.warning = QLabel("")
|
|
2150
|
+
self.warning.setWordWrap(True)
|
|
2151
|
+
self.warning.setStyleSheet("color: #e05a5a;")
|
|
2152
|
+
layout.addWidget(self.warning)
|
|
2153
|
+
note = QLabel("Saved with the session. Same size and margins, "
|
|
2154
|
+
"same axes box; exports come out at exactly this "
|
|
2155
|
+
"size.")
|
|
2156
|
+
note.setWordWrap(True)
|
|
2157
|
+
note.setStyleSheet("color: #9a9a9a;")
|
|
2158
|
+
layout.addWidget(note)
|
|
2159
|
+
|
|
2160
|
+
self.make_default = QPushButton("Use for new figures")
|
|
2161
|
+
self.make_default.setToolTip("New figures start with this "
|
|
2162
|
+
"layout.")
|
|
2163
|
+
self.make_default.clicked.connect(lambda _c=False: self.set_default())
|
|
2164
|
+
buttons = self._buttons()
|
|
2165
|
+
row = QHBoxLayout()
|
|
2166
|
+
row.addWidget(self.make_default)
|
|
2167
|
+
row.addStretch(1)
|
|
2168
|
+
row.addWidget(buttons)
|
|
2169
|
+
layout.addLayout(row)
|
|
2170
|
+
|
|
2171
|
+
self._show()
|
|
2172
|
+
for box in (self.fig_w, self.fig_h, self.margin_l, self.margin_r,
|
|
2173
|
+
self.margin_t, self.margin_b, self.aspect_w,
|
|
2174
|
+
self.aspect_h):
|
|
2175
|
+
box.valueChanged.connect(self._apply)
|
|
2176
|
+
self.axes_w.valueChanged.connect(self._axes_typed)
|
|
2177
|
+
self.axes_h.valueChanged.connect(self._axes_typed)
|
|
2178
|
+
self.dpi.valueChanged.connect(self._apply)
|
|
2179
|
+
self.mode.currentIndexChanged.connect(self._apply)
|
|
2180
|
+
self.unit.currentIndexChanged.connect(self._unit_changed)
|
|
2181
|
+
|
|
2182
|
+
def _boxes(self):
|
|
2183
|
+
return (self.fig_w, self.fig_h, self.margin_l, self.margin_r,
|
|
2184
|
+
self.margin_t, self.margin_b, self.axes_w, self.axes_h,
|
|
2185
|
+
self.aspect_w, self.aspect_h, self.dpi)
|
|
2186
|
+
|
|
2187
|
+
def _show(self):
|
|
2188
|
+
"""Put the layout's numbers in the boxes, without writing back."""
|
|
2189
|
+
fig = self.obj
|
|
2190
|
+
for box in self._boxes():
|
|
2191
|
+
box.blockSignals(True)
|
|
2192
|
+
suffix = " " + fig.unit
|
|
2193
|
+
for box, value in ((self.fig_w, fig.width), (self.fig_h, fig.height),
|
|
2194
|
+
(self.margin_l, fig.margin_left),
|
|
2195
|
+
(self.margin_r, fig.margin_right),
|
|
2196
|
+
(self.margin_t, fig.margin_top),
|
|
2197
|
+
(self.margin_b, fig.margin_bottom)):
|
|
2198
|
+
box.setSuffix(suffix)
|
|
2199
|
+
box.setValue(float(value))
|
|
2200
|
+
axes_w, axes_h = fig.axes_size()
|
|
2201
|
+
for box, value in ((self.axes_w, axes_w), (self.axes_h, axes_h)):
|
|
2202
|
+
box.setSuffix(suffix)
|
|
2203
|
+
box.setValue(max(0.0, float(value)))
|
|
2204
|
+
self.aspect_w.setValue(float(fig.aspect_w))
|
|
2205
|
+
self.aspect_h.setValue(float(fig.aspect_h))
|
|
2206
|
+
self.dpi.setValue(int(fig.dpi))
|
|
2207
|
+
for box in self._boxes():
|
|
2208
|
+
box.blockSignals(False)
|
|
2209
|
+
size_mode = fig.mode == figure_module.MODE_SIZE
|
|
2210
|
+
for box in (self.fig_w, self.fig_h, self.margin_l, self.margin_r,
|
|
2211
|
+
self.margin_t, self.margin_b, self.axes_w, self.axes_h,
|
|
2212
|
+
self.dpi, self.unit):
|
|
2213
|
+
box.setEnabled(size_mode)
|
|
2214
|
+
for box in (self.aspect_w, self.aspect_h):
|
|
2215
|
+
box.setEnabled(fig.mode == figure_module.MODE_ASPECT)
|
|
2216
|
+
self._describe()
|
|
2217
|
+
|
|
2218
|
+
def _describe(self):
|
|
2219
|
+
"""What does not fit in its margin, in the layout's unit."""
|
|
2220
|
+
if self.plot is None:
|
|
2221
|
+
self.warning.setText("")
|
|
2222
|
+
return
|
|
2223
|
+
fig = self.obj
|
|
2224
|
+
lines = []
|
|
2225
|
+
for side, need, have in self.plot.overflow():
|
|
2226
|
+
to_unit = figure_module.PER_INCH[fig.unit] \
|
|
2227
|
+
/ figure_module.DESIGN_DPI
|
|
2228
|
+
lines.append("The {} margin is {:.2f} {} and what it holds "
|
|
2229
|
+
"needs {:.2f} {}: something drawn there will be "
|
|
2230
|
+
"cut off.".format(side, have * to_unit, fig.unit,
|
|
2231
|
+
need * to_unit, fig.unit))
|
|
2232
|
+
if not fig.is_valid():
|
|
2233
|
+
lines.append("The margins leave no room for the axes box.")
|
|
2234
|
+
self.warning.setText("\n".join(lines))
|
|
2235
|
+
|
|
2236
|
+
def _apply(self, *_args):
|
|
2237
|
+
fig = self.obj
|
|
2238
|
+
fig.mode = self.mode.currentData()
|
|
2239
|
+
fig.width = float(self.fig_w.value())
|
|
2240
|
+
fig.height = float(self.fig_h.value())
|
|
2241
|
+
fig.margin_left = float(self.margin_l.value())
|
|
2242
|
+
fig.margin_right = float(self.margin_r.value())
|
|
2243
|
+
fig.margin_top = float(self.margin_t.value())
|
|
2244
|
+
fig.margin_bottom = float(self.margin_b.value())
|
|
2245
|
+
fig.aspect_w = float(self.aspect_w.value())
|
|
2246
|
+
fig.aspect_h = float(self.aspect_h.value())
|
|
2247
|
+
fig.dpi = int(self.dpi.value())
|
|
2248
|
+
self._live()
|
|
2249
|
+
self._show()
|
|
2250
|
+
|
|
2251
|
+
def _axes_typed(self, *_args):
|
|
2252
|
+
"""An axes box typed directly: the figure grows round it."""
|
|
2253
|
+
self.obj.set_axes_size(float(self.axes_w.value()),
|
|
2254
|
+
float(self.axes_h.value()))
|
|
2255
|
+
self._live()
|
|
2256
|
+
self._show()
|
|
2257
|
+
|
|
2258
|
+
def _unit_changed(self, *_args):
|
|
2259
|
+
self.obj.set_unit(self.unit.currentData())
|
|
2260
|
+
self._live()
|
|
2261
|
+
self._show()
|
|
2262
|
+
|
|
2263
|
+
def set_default(self):
|
|
2264
|
+
"""This layout, for every NEW figure from now on."""
|
|
2265
|
+
style.set_figure_default(self.obj)
|
|
2266
|
+
try:
|
|
2267
|
+
style.save_preferences()
|
|
2268
|
+
except OSError:
|
|
2269
|
+
pass
|
|
2270
|
+
self.make_default.setText("New figures use this")
|
|
2271
|
+
|
|
2272
|
+
|
|
2273
|
+
class LabelSettings(_LiveDialog):
|
|
2274
|
+
"""A caption the user placed: its text, size and colour."""
|
|
2275
|
+
|
|
2276
|
+
FIELDS = ("text", "colour", "size", "bold", "x", "y", "space",
|
|
2277
|
+
"anchor", "rotation", "leader", "leader_from", "leader_colour",
|
|
2278
|
+
"flush", "vline", "line_dashed", "at", "dx", "dy")
|
|
2279
|
+
INDIVIDUAL = ("text", "x", "y", "space", "leader", "vline", "at", "dx",
|
|
2280
|
+
"dy")
|
|
2281
|
+
GROUP_DISABLED = ("text", "transform.space", "transform.at_x",
|
|
2282
|
+
"transform.at_y")
|
|
2283
|
+
|
|
2284
|
+
def __init__(self, parent, label, on_change=None):
|
|
2285
|
+
# A label that has followed its scan shows where it IS, not where
|
|
2286
|
+
# it was put (`PlotWidget.rebase`; nothing moves).
|
|
2287
|
+
plot = getattr(parent, "plot", None)
|
|
2288
|
+
if plot is not None:
|
|
2289
|
+
plot.rebase(label)
|
|
2290
|
+
_LiveDialog.__init__(self, parent, label, on_change)
|
|
2291
|
+
self.setWindowTitle("Label")
|
|
2292
|
+
layout = QVBoxLayout(self)
|
|
2293
|
+
form = QFormLayout()
|
|
2294
|
+
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
2295
|
+
layout.addLayout(form)
|
|
2296
|
+
|
|
2297
|
+
# Several lines: Enter breaks one, Tab goes on to the next field.
|
|
2298
|
+
self.text = QPlainTextEdit(label.text)
|
|
2299
|
+
self.text.setTabChangesFocus(True)
|
|
2300
|
+
self.text.setFixedHeight(3 * self.fontMetrics().lineSpacing() + 14)
|
|
2301
|
+
self.text.setToolTip("*T* italic, _{g} subscript, \\Delta Greek, "
|
|
2302
|
+
"LaTeX between $...$. Enter starts a new line.")
|
|
2303
|
+
form.addRow("Text", self.text)
|
|
2304
|
+
|
|
2305
|
+
self.text_size = _style_number(self, label, "size")
|
|
2306
|
+
form.addRow("Size", self.text_size)
|
|
2307
|
+
|
|
2308
|
+
self.bold = QCheckBox("Bold")
|
|
2309
|
+
self.bold.setChecked(bool(label.bold))
|
|
2310
|
+
form.addRow("", self.bold)
|
|
2311
|
+
|
|
2312
|
+
self.transform = ArtistTransform(label, getattr(parent, "plot", None),
|
|
2313
|
+
on_change=self._live, parent=self)
|
|
2314
|
+
form.addRow("Place", self.transform)
|
|
2315
|
+
|
|
2316
|
+
# A label that belongs to a scan hangs from its curve:
|
|
2317
|
+
# where along it, and how far from it - an analysis label's rows.
|
|
2318
|
+
self.hang_at = QLineEdit(self)
|
|
2319
|
+
self.hang_at.setToolTip("The temperature on its curve it hangs "
|
|
2320
|
+
"from. 98 is in the axis unit; 98 F, 371 K "
|
|
2321
|
+
"convert.")
|
|
2322
|
+
self.hang_dy = NumberBox()
|
|
2323
|
+
self.hang_dx = NumberBox()
|
|
2324
|
+
for box in (self.hang_dy, self.hang_dx):
|
|
2325
|
+
box.setDecimals(1)
|
|
2326
|
+
box.setRange(-2000.0, 2000.0)
|
|
2327
|
+
box.setSuffix(" px")
|
|
2328
|
+
self.hang_dy.setToolTip("How far above the curve (below: "
|
|
2329
|
+
"negative). Drag it up and down.")
|
|
2330
|
+
self.hang_dx.setToolTip("How far to the right of its point.")
|
|
2331
|
+
form.addRow("On the curve at", self.hang_at)
|
|
2332
|
+
form.addRow("Distance", self.hang_dy)
|
|
2333
|
+
form.addRow("Sideways", self.hang_dx)
|
|
2334
|
+
self._hang_rows = (self.hang_at, self.hang_dy, self.hang_dx)
|
|
2335
|
+
self._show_hang()
|
|
2336
|
+
self.hang_at.editingFinished.connect(self._typed_hang_at)
|
|
2337
|
+
self.hang_dy.valueChanged.connect(self._typed_hang)
|
|
2338
|
+
self.hang_dx.valueChanged.connect(self._typed_hang)
|
|
2339
|
+
|
|
2340
|
+
form.addRow("Colour", _colour_button(
|
|
2341
|
+
self, lambda: (label.colour if label.colour != "auto"
|
|
2342
|
+
else label.scan.colour if label.scan is not None
|
|
2343
|
+
else "#cccccc"), self._set_colour))
|
|
2344
|
+
# Automatic is its curve's colour for a label that belongs to one
|
|
2345
|
+
# (`PlotWidget.label_colour`), the theme's ink for a free one -
|
|
2346
|
+
# and says which.
|
|
2347
|
+
self.auto = QCheckBox("Same as parent" if label.scan is not None
|
|
2348
|
+
else "Follow the theme")
|
|
2349
|
+
self.auto.setToolTip("The colour of the curve it belongs to."
|
|
2350
|
+
if label.scan is not None
|
|
2351
|
+
else "The theme's ink.")
|
|
2352
|
+
self.auto.setChecked(label.colour in (None, "", "auto"))
|
|
2353
|
+
form.addRow("", self.auto)
|
|
2354
|
+
|
|
2355
|
+
# A NOTE is a label with an arrow to a point.
|
|
2356
|
+
self.leader = QCheckBox("Leader arrow (a note)")
|
|
2357
|
+
self.leader.setChecked(bool(label.leader))
|
|
2358
|
+
self.leader.setToolTip("An arrow from the text to a point. Select "
|
|
2359
|
+
"the note and drag the ring at its tip; near "
|
|
2360
|
+
"a curve it snaps onto it.")
|
|
2361
|
+
form.addRow("", self.leader)
|
|
2362
|
+
|
|
2363
|
+
# The point it names, typed, as well as the text's place.
|
|
2364
|
+
tip = QWidget(self)
|
|
2365
|
+
row = QHBoxLayout(tip)
|
|
2366
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
2367
|
+
self.tip_x = QLineEdit(self)
|
|
2368
|
+
self.tip_x.setToolTip("The temperature it points at. 98 is in the "
|
|
2369
|
+
"axis unit; 98 F, 371 K convert.")
|
|
2370
|
+
self.tip_y = QLineEdit(self)
|
|
2371
|
+
self.tip_y.setToolTip("The height it points at, in its axis's "
|
|
2372
|
+
"unit.")
|
|
2373
|
+
self.tip_unit = QLabel("", self)
|
|
2374
|
+
row.addWidget(self.tip_x)
|
|
2375
|
+
row.addWidget(QLabel("at", self))
|
|
2376
|
+
row.addWidget(self.tip_y)
|
|
2377
|
+
row.addWidget(self.tip_unit)
|
|
2378
|
+
form.addRow("Points at", tip)
|
|
2379
|
+
self.leader_from = QComboBox()
|
|
2380
|
+
self.leader_from.addItem("Nearest edge", "auto")
|
|
2381
|
+
for name in units_module.ANCHORS:
|
|
2382
|
+
self.leader_from.addItem(name, name)
|
|
2383
|
+
self.leader_from.setCurrentIndex(max(0, self.leader_from.findData(
|
|
2384
|
+
label.leader_from)))
|
|
2385
|
+
self.leader_from.setToolTip("Where on the text the arrow starts.")
|
|
2386
|
+
form.addRow("Arrow from", self.leader_from)
|
|
2387
|
+
self.arrow_colour = _colour_button(
|
|
2388
|
+
self, lambda: (label.leader_colour
|
|
2389
|
+
if label.leader_colour not in (None, "", "auto")
|
|
2390
|
+
else (label.colour if label.colour != "auto"
|
|
2391
|
+
else "#cccccc")), self._set_leader_colour)
|
|
2392
|
+
form.addRow("Arrow colour", self.arrow_colour)
|
|
2393
|
+
self.leader_auto = QCheckBox("Same as text")
|
|
2394
|
+
self.leader_auto.setChecked(label.leader_colour in (None, "", "auto"))
|
|
2395
|
+
form.addRow("", self.leader_auto)
|
|
2396
|
+
self._note_rows = (tip, self.leader_from, self.arrow_colour,
|
|
2397
|
+
self.leader_auto)
|
|
2398
|
+
# On its curve a note's arrow drops straight onto its point: no
|
|
2399
|
+
# point to type apart from where it hangs, no edge to leave from.
|
|
2400
|
+
if label.attached:
|
|
2401
|
+
for widget in (tip, self.leader_from):
|
|
2402
|
+
form.labelForField(widget).setVisible(False)
|
|
2403
|
+
widget.setVisible(False)
|
|
2404
|
+
self._show_tip()
|
|
2405
|
+
|
|
2406
|
+
# A MARKER LINE's temperature, typed, and its style.
|
|
2407
|
+
self.line_at = QLineEdit(self)
|
|
2408
|
+
self.line_at.setToolTip("Where the line stands. 98 is in the axis "
|
|
2409
|
+
"unit; 98 F, 371 K convert.")
|
|
2410
|
+
self.line_dashed = QCheckBox("Dashed")
|
|
2411
|
+
self.line_dashed.setChecked(bool(label.line_dashed))
|
|
2412
|
+
line = QWidget(self)
|
|
2413
|
+
line_row = QHBoxLayout(line)
|
|
2414
|
+
line_row.setContentsMargins(0, 0, 0, 0)
|
|
2415
|
+
line_row.addWidget(self.line_at)
|
|
2416
|
+
line_row.addWidget(self.line_dashed)
|
|
2417
|
+
form.addRow("Line at", line)
|
|
2418
|
+
form.labelForField(line).setVisible(label.is_vline)
|
|
2419
|
+
line.setVisible(label.is_vline)
|
|
2420
|
+
if label.is_vline and plot is not None:
|
|
2421
|
+
self.line_at.setText("{:.4g}".format(float(plot.to_axis(
|
|
2422
|
+
label.vline))))
|
|
2423
|
+
self.line_at.editingFinished.connect(self._typed_line)
|
|
2424
|
+
self.line_dashed.toggled.connect(self._apply)
|
|
2425
|
+
|
|
2426
|
+
buttons = self._buttons()
|
|
2427
|
+
layout.addWidget(buttons)
|
|
2428
|
+
|
|
2429
|
+
self.text.textChanged.connect(self._apply)
|
|
2430
|
+
self.text_size.changed.connect(self._apply)
|
|
2431
|
+
self.bold.toggled.connect(self._apply)
|
|
2432
|
+
self.auto.toggled.connect(self._apply)
|
|
2433
|
+
self.leader.toggled.connect(self._leader_toggled)
|
|
2434
|
+
self.leader_from.currentIndexChanged.connect(self._apply)
|
|
2435
|
+
self.leader_auto.toggled.connect(self._apply)
|
|
2436
|
+
self.tip_x.editingFinished.connect(self._typed_tip)
|
|
2437
|
+
self.tip_y.editingFinished.connect(self._typed_tip)
|
|
2438
|
+
|
|
2439
|
+
def _show_hang(self):
|
|
2440
|
+
"""The rows of a label hanging from its curve, shown only then."""
|
|
2441
|
+
label = self.obj
|
|
2442
|
+
on = bool(getattr(label, "attached", False))
|
|
2443
|
+
form = self.findChildren(QFormLayout)[0]
|
|
2444
|
+
for widget in self._hang_rows:
|
|
2445
|
+
visible = on and not (widget is self.hang_dx and label.leader)
|
|
2446
|
+
widget.setVisible(visible)
|
|
2447
|
+
caption = form.labelForField(widget)
|
|
2448
|
+
if caption is not None:
|
|
2449
|
+
caption.setVisible(visible)
|
|
2450
|
+
if not on:
|
|
2451
|
+
return
|
|
2452
|
+
self.transform.hide_position(anchor_too=bool(label.leader))
|
|
2453
|
+
plot = getattr(self.parent(), "plot", None)
|
|
2454
|
+
celsius = plot.attached_celsius(label) if plot is not None else None
|
|
2455
|
+
for widget in self._hang_rows:
|
|
2456
|
+
widget.blockSignals(True)
|
|
2457
|
+
if celsius is not None:
|
|
2458
|
+
unit = getattr(self.doc, "x_unit", units.TEMP_C)
|
|
2459
|
+
self.hang_at.setText("{} {}".format(
|
|
2460
|
+
numbers.write(float(plot.to_axis(celsius)), "%.2f"),
|
|
2461
|
+
units.TEMPERATURE_LABEL.get(unit, unit)))
|
|
2462
|
+
self.hang_dy.setValue(-float(plot.label_dy(label))
|
|
2463
|
+
if plot is not None else 0.0)
|
|
2464
|
+
self.hang_dx.setValue(float(label.dx or 0.0))
|
|
2465
|
+
for widget in self._hang_rows:
|
|
2466
|
+
widget.blockSignals(False)
|
|
2467
|
+
|
|
2468
|
+
def _typed_hang_at(self):
|
|
2469
|
+
label = self.obj
|
|
2470
|
+
plot = getattr(self.parent(), "plot", None)
|
|
2471
|
+
if not label.attached or plot is None:
|
|
2472
|
+
return
|
|
2473
|
+
doc = self.doc
|
|
2474
|
+
unit = getattr(doc, "x_unit", units.TEMP_C) if doc else units.TEMP_C
|
|
2475
|
+
celsius = units.parse_temperature(self.hang_at.text(), unit)
|
|
2476
|
+
at = (plot.attach_at_celsius(label, celsius)
|
|
2477
|
+
if celsius is not None else None)
|
|
2478
|
+
if at is None:
|
|
2479
|
+
self.hang_at.setStyleSheet("border: 1px solid #d04040;")
|
|
2480
|
+
return
|
|
2481
|
+
self.hang_at.setStyleSheet("")
|
|
2482
|
+
if tuple(at) != tuple(label.at):
|
|
2483
|
+
label.at = tuple(at)
|
|
2484
|
+
self._live()
|
|
2485
|
+
|
|
2486
|
+
def _typed_hang(self, _value=0.0):
|
|
2487
|
+
label = self.obj
|
|
2488
|
+
if not label.attached:
|
|
2489
|
+
return
|
|
2490
|
+
label.dy = -float(self.hang_dy.value())
|
|
2491
|
+
if not label.leader:
|
|
2492
|
+
label.dx = float(self.hang_dx.value())
|
|
2493
|
+
self._live()
|
|
2494
|
+
|
|
2495
|
+
def _typed_line(self):
|
|
2496
|
+
label = self.obj
|
|
2497
|
+
if not label.is_vline:
|
|
2498
|
+
return
|
|
2499
|
+
doc = self.doc
|
|
2500
|
+
unit = getattr(doc, "x_unit", units.TEMP_C) if doc else units.TEMP_C
|
|
2501
|
+
celsius = units.parse_temperature(self.line_at.text(), unit)
|
|
2502
|
+
if celsius is None:
|
|
2503
|
+
self.line_at.setStyleSheet("border: 1px solid #d04040;")
|
|
2504
|
+
return
|
|
2505
|
+
self.line_at.setStyleSheet("")
|
|
2506
|
+
if abs(float(celsius) - float(label.vline)) > 1e-9:
|
|
2507
|
+
label.vline = float(celsius)
|
|
2508
|
+
self._live()
|
|
2509
|
+
|
|
2510
|
+
def _set_leader_colour(self, name):
|
|
2511
|
+
self.obj.leader_colour = name
|
|
2512
|
+
self.leader_auto.setChecked(False)
|
|
2513
|
+
self._live()
|
|
2514
|
+
|
|
2515
|
+
def _show_tip(self):
|
|
2516
|
+
"""The point a note names, in the axes' units; the note rows only
|
|
2517
|
+
while it is a note."""
|
|
2518
|
+
label = self.obj
|
|
2519
|
+
plot = getattr(self.parent(), "plot", None)
|
|
2520
|
+
on = bool(label.leader)
|
|
2521
|
+
for widget in self._note_rows:
|
|
2522
|
+
widget.setEnabled(on)
|
|
2523
|
+
if not on or plot is None:
|
|
2524
|
+
self.tip_x.setText("")
|
|
2525
|
+
self.tip_y.setText("")
|
|
2526
|
+
return
|
|
2527
|
+
self.tip_x.setText("{:.4g}".format(float(plot.to_axis(
|
|
2528
|
+
label.leader[0]))))
|
|
2529
|
+
self.tip_y.setText("{:.5g}".format(float(label.leader[1])
|
|
2530
|
+
+ label.follow()))
|
|
2531
|
+
self.tip_unit.setText(plot._typed_unit(label, "y"))
|
|
2532
|
+
|
|
2533
|
+
def _typed_tip(self):
|
|
2534
|
+
"""The point typed: a temperature in any unit, a height in the
|
|
2535
|
+
axis's."""
|
|
2536
|
+
label = self.obj
|
|
2537
|
+
if not label.leader:
|
|
2538
|
+
return
|
|
2539
|
+
doc = self.doc
|
|
2540
|
+
unit = getattr(doc, "x_unit", units.TEMP_C) if doc else units.TEMP_C
|
|
2541
|
+
celsius = units.parse_temperature(self.tip_x.text(), unit)
|
|
2542
|
+
height = numbers.evaluate(self.tip_y.text())
|
|
2543
|
+
if celsius is None or height is None:
|
|
2544
|
+
box = self.tip_x if celsius is None else self.tip_y
|
|
2545
|
+
box.setStyleSheet("border: 1px solid #d04040;")
|
|
2546
|
+
return
|
|
2547
|
+
self.tip_x.setStyleSheet("")
|
|
2548
|
+
self.tip_y.setStyleSheet("")
|
|
2549
|
+
new = [float(celsius), float(height) - label.follow()]
|
|
2550
|
+
if new != list(label.leader):
|
|
2551
|
+
label.leader = new
|
|
2552
|
+
self._live()
|
|
2553
|
+
|
|
2554
|
+
def _set_colour(self, name):
|
|
2555
|
+
self.obj.colour = name
|
|
2556
|
+
if hasattr(self, "auto"):
|
|
2557
|
+
self.auto.setChecked(False)
|
|
2558
|
+
self._live()
|
|
2559
|
+
|
|
2560
|
+
def _leader_toggled(self, on):
|
|
2561
|
+
"""On: an arrow down and to the left of the text, to be dragged
|
|
2562
|
+
where it belongs. Off: a plain label again."""
|
|
2563
|
+
label = self.obj
|
|
2564
|
+
plot = getattr(self.parent(), "plot", None)
|
|
2565
|
+
if not on:
|
|
2566
|
+
label.leader = None
|
|
2567
|
+
elif not label.leader and plot is not None:
|
|
2568
|
+
rect = plot.plot_rect()
|
|
2569
|
+
box = plot.rotated_bounds(label, plot.artist_box(label, rect),
|
|
2570
|
+
rect)
|
|
2571
|
+
tip = QPointF(box.center().x(), box.bottom() + 36.0)
|
|
2572
|
+
label.leader = plot.leader_value(label, tip, rect)
|
|
2573
|
+
self._show_tip()
|
|
2574
|
+
self._live()
|
|
2575
|
+
|
|
2576
|
+
def _apply(self, *_args):
|
|
2577
|
+
label = self.obj
|
|
2578
|
+
label.text = self.text.toPlainText() or "Label"
|
|
2579
|
+
label.size = self.text_size.value()
|
|
2580
|
+
label.bold = bool(self.bold.isChecked())
|
|
2581
|
+
if self.auto.isChecked():
|
|
2582
|
+
label.colour = "auto"
|
|
2583
|
+
label.leader_from = self.leader_from.currentData() or "auto"
|
|
2584
|
+
if self.leader_auto.isChecked():
|
|
2585
|
+
label.leader_colour = "auto"
|
|
2586
|
+
label.line_dashed = bool(self.line_dashed.isChecked())
|
|
2587
|
+
self._live()
|
|
2588
|
+
|
|
2589
|
+
|
|
2590
|
+
class AnalysisSettings(_LiveDialog):
|
|
2591
|
+
"""One analysis: what it is, its interval, and how it is labelled.
|
|
2592
|
+
|
|
2593
|
+
The model and the interval are recomputed IN PLACE by the window
|
|
2594
|
+
(`MainWindow.change_model`, `retype_interval`), each as its own undo
|
|
2595
|
+
step, exactly as dragging a gizmo is; everything else here is styling
|
|
2596
|
+
and goes into this window's one step.
|
|
2597
|
+
"""
|
|
2598
|
+
|
|
2599
|
+
FIELDS = ("visible", "colour", "label", "label_size", "flush",
|
|
2600
|
+
"show_interval", "interval_size", "construction", "shade",
|
|
2601
|
+
"shading",
|
|
2602
|
+
"show_peak",
|
|
2603
|
+
"number_format",
|
|
2604
|
+
"unit", "label_dy")
|
|
2605
|
+
INDIVIDUAL = ("label",)
|
|
2606
|
+
GROUP_DISABLED = ("label", "model", "start", "end")
|
|
2607
|
+
|
|
2608
|
+
def __init__(self, parent, analysis, on_change=None):
|
|
2609
|
+
_LiveDialog.__init__(self, parent, analysis, on_change)
|
|
2610
|
+
self.setWindowTitle(analysis.model_name)
|
|
2611
|
+
layout = QVBoxLayout(self)
|
|
2612
|
+
form = QFormLayout()
|
|
2613
|
+
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
2614
|
+
layout.addLayout(form)
|
|
2615
|
+
|
|
2616
|
+
self.model = QComboBox()
|
|
2617
|
+
# The models of ITS curve: a heat flow's, or a mass's.
|
|
2618
|
+
for entry in measure.models_for(analysis.scan):
|
|
2619
|
+
self.model.addItem(entry.title, entry.name)
|
|
2620
|
+
if self.model.findData(analysis.model_name) < 0:
|
|
2621
|
+
self.model.addItem(analysis.model_name, analysis.model_name)
|
|
2622
|
+
self.model.setCurrentIndex(self.model.findData(analysis.model_name))
|
|
2623
|
+
self.model.setToolTip("Analysis type. Changing it recomputes on the "
|
|
2624
|
+
"same interval.")
|
|
2625
|
+
form.addRow("Model", self.model)
|
|
2626
|
+
|
|
2627
|
+
self.source = QLabel("")
|
|
2628
|
+
self.source.setToolTip("Where the numbers were computed.")
|
|
2629
|
+
form.addRow("From", self.source)
|
|
2630
|
+
|
|
2631
|
+
# The interval: two numbers, typed in any temperature unit.
|
|
2632
|
+
self.start = QLineEdit(self)
|
|
2633
|
+
self.end = QLineEdit(self)
|
|
2634
|
+
for box, word in ((self.start, "start"), (self.end, "end")):
|
|
2635
|
+
box.setToolTip("Interval {}. 98 is in the axis unit; 98 F, "
|
|
2636
|
+
"371 K, 98 C convert.".format(word))
|
|
2637
|
+
box.editingFinished.connect(self._typed_interval)
|
|
2638
|
+
form.addRow("Start", self.start)
|
|
2639
|
+
form.addRow("End", self.end)
|
|
2640
|
+
entry = measure.by_name(analysis.model_name)
|
|
2641
|
+
#: One temperature (a mass at a temperature) or an interval.
|
|
2642
|
+
self.one_cursor = bool(entry is not None and entry.needs == 1)
|
|
2643
|
+
if self.one_cursor:
|
|
2644
|
+
form.labelForField(self.start).setText("At")
|
|
2645
|
+
self.start.setToolTip("The temperature. 98 is in the axis unit; "
|
|
2646
|
+
"98 F, 371 K, 98 C convert.")
|
|
2647
|
+
form.labelForField(self.end).setVisible(False)
|
|
2648
|
+
self.end.setVisible(False)
|
|
2649
|
+
|
|
2650
|
+
self.results = QLabel("")
|
|
2651
|
+
self.results.setWordWrap(True)
|
|
2652
|
+
self.results.setToolTip("Everything the analysis computed.")
|
|
2653
|
+
form.addRow("Results", self.results)
|
|
2654
|
+
|
|
2655
|
+
# No "Drawn on": an analysis from a .txt export is offered under
|
|
2656
|
+
# every scan with its step name and is attributed by being shown on
|
|
2657
|
+
# the one it belongs to (`Sample.analyses_for`).
|
|
2658
|
+
|
|
2659
|
+
self.visible = QCheckBox("Show")
|
|
2660
|
+
self.visible.setChecked(bool(analysis.visible))
|
|
2661
|
+
self.visible.setToolTip("Draw this analysis.")
|
|
2662
|
+
form.addRow("", self.visible)
|
|
2663
|
+
|
|
2664
|
+
self.interval = QCheckBox("Show interval markers")
|
|
2665
|
+
self.interval.setChecked(bool(analysis.show_interval))
|
|
2666
|
+
self.interval.setToolTip("Dashes at the interval ends, on the "
|
|
2667
|
+
"curve.")
|
|
2668
|
+
form.addRow("", self.interval)
|
|
2669
|
+
self.interval_size = _style_number(self, analysis, "interval_size")
|
|
2670
|
+
self.interval_size.setToolTip("Half the length of each dash.")
|
|
2671
|
+
form.addRow("Marker length", self.interval_size)
|
|
2672
|
+
|
|
2673
|
+
# The shading of an integration, and whether it lets things show
|
|
2674
|
+
# through. Only an integration is shaded.
|
|
2675
|
+
self.shade = QCheckBox("Shade the area")
|
|
2676
|
+
self.shade.setChecked(bool(analysis.shade))
|
|
2677
|
+
self.shade.setToolTip("Fill the integrated area between the curve "
|
|
2678
|
+
"and its baseline.")
|
|
2679
|
+
# Translucent, or opaque in the colour the translucent fill makes
|
|
2680
|
+
# over the page; the house style's until chosen, like "Lines".
|
|
2681
|
+
self.opaque = StyleChoice(
|
|
2682
|
+
style.SHADINGS, getattr(analysis, "shading", None),
|
|
2683
|
+
lambda: style.SHADING_TITLES.get(
|
|
2684
|
+
style.inherited(self.doc, self.obj, "shading"), ""),
|
|
2685
|
+
parent=self, titles=style.SHADING_TITLES)
|
|
2686
|
+
self.opaque.setEnabled(bool(analysis.shade))
|
|
2687
|
+
self.opaque.setToolTip("Opaque: the colour the translucent shading "
|
|
2688
|
+
"makes over the background, so nothing "
|
|
2689
|
+
"behind it shows through.")
|
|
2690
|
+
integration = "Integration" in analysis.model_name
|
|
2691
|
+
form.addRow("", self.shade)
|
|
2692
|
+
form.addRow("Shading", self.opaque)
|
|
2693
|
+
# Its peak temperature in the label as well.
|
|
2694
|
+
self.peak = StyleChoice(
|
|
2695
|
+
style.PEAKS, getattr(analysis, "show_peak", None),
|
|
2696
|
+
lambda: style.PEAK_TITLES.get(
|
|
2697
|
+
style.inherited(self.doc, self.obj, "show_peak"), ""),
|
|
2698
|
+
parent=self, titles=style.PEAK_TITLES)
|
|
2699
|
+
self.peak.setToolTip("The peak temperature after the enthalpy: "
|
|
2700
|
+
"*T*_{p} = 124 \u00b0C. {Tp} in the label "
|
|
2701
|
+
"puts it anywhere.")
|
|
2702
|
+
form.addRow("Peak (Tp)", self.peak)
|
|
2703
|
+
for box in (self.shade, self.opaque, self.peak):
|
|
2704
|
+
box.setVisible(integration)
|
|
2705
|
+
form.labelForField(self.opaque).setVisible(integration)
|
|
2706
|
+
form.labelForField(self.peak).setVisible(integration)
|
|
2707
|
+
|
|
2708
|
+
# The lines of an onset, endset or Tg, following the house style
|
|
2709
|
+
# until chosen, like the alignment below. Only those have lines.
|
|
2710
|
+
self.lines = StyleChoice(
|
|
2711
|
+
style.LINES, analysis.construction,
|
|
2712
|
+
lambda: style.LINES_TITLES.get(
|
|
2713
|
+
style.inherited(self.doc, self.obj, "construction"), ""),
|
|
2714
|
+
parent=self, titles=style.LINES_TITLES)
|
|
2715
|
+
self.lines.setToolTip("Tangent construction, chords to the point, "
|
|
2716
|
+
"or none.")
|
|
2717
|
+
form.addRow("Lines", self.lines)
|
|
2718
|
+
# Why there are no tangents to draw, when there are none: one line.
|
|
2719
|
+
self.lines_note = QLabel("")
|
|
2720
|
+
self.lines_note.setWordWrap(True)
|
|
2721
|
+
self.lines_note.setStyleSheet("color: #9a9a9a;")
|
|
2722
|
+
self.lines_note.setVisible(False)
|
|
2723
|
+
form.addRow("", self.lines_note)
|
|
2724
|
+
|
|
2725
|
+
self.text_size = _style_number(self, analysis, "label_size")
|
|
2726
|
+
self.text_size.setToolTip("Label size, pt.")
|
|
2727
|
+
form.addRow("Label size", self.text_size)
|
|
2728
|
+
|
|
2729
|
+
# The template's `flush`: which edge of the text sits on the arrow.
|
|
2730
|
+
self.flush = StyleChoice(
|
|
2731
|
+
style.FLUSHES, analysis.flush,
|
|
2732
|
+
lambda: style.FLUSH_TITLES[style.flush_for(
|
|
2733
|
+
analysis, style.inherited(self.doc, analysis, "flush"))],
|
|
2734
|
+
parent=self, titles=style.FLUSH_TITLES)
|
|
2735
|
+
self.flush.setToolTip("Which edge of the label sits on its arrow.")
|
|
2736
|
+
form.addRow("Alignment", self.flush)
|
|
2737
|
+
|
|
2738
|
+
arrow_offset_rows(self, form)
|
|
2739
|
+
|
|
2740
|
+
# A TEMPLATE: the words are the user's, `{}` is the measurement
|
|
2741
|
+
# (`core/labels.py`), so the number can never go stale or be typed.
|
|
2742
|
+
self.label = QLineEdit(analysis.label or "")
|
|
2743
|
+
self.label.setPlaceholderText(labels.default_template(analysis))
|
|
2744
|
+
self.label.setToolTip("Your text; {} is the measured value. A unit "
|
|
2745
|
+
"after it converts: {} \u00b0F.")
|
|
2746
|
+
form.addRow("Label", self.label)
|
|
2747
|
+
|
|
2748
|
+
self.number_format = _style_text(self, analysis, "number_format")
|
|
2749
|
+
form.addRow("Number format", self.number_format)
|
|
2750
|
+
|
|
2751
|
+
# Which unit the number is in: the axes' until chosen. A per-mole
|
|
2752
|
+
# one needs the molar mass, and says so rather than guessing.
|
|
2753
|
+
self.unit = QComboBox()
|
|
2754
|
+
self.unit.addItem("As the axes", None)
|
|
2755
|
+
for name in labels.units_of(analysis.quantity):
|
|
2756
|
+
self.unit.addItem(name, name)
|
|
2757
|
+
found = self.unit.findData(analysis.unit)
|
|
2758
|
+
self.unit.setCurrentIndex(found if found >= 0 else 0)
|
|
2759
|
+
self.unit.setToolTip("The unit of the number. kJ/mol and J/mol need "
|
|
2760
|
+
"the molar mass.")
|
|
2761
|
+
form.addRow("Unit", self.unit)
|
|
2762
|
+
|
|
2763
|
+
self.preview = QLabel("")
|
|
2764
|
+
self.preview.setWordWrap(True)
|
|
2765
|
+
self.preview.setToolTip("The label as drawn.")
|
|
2766
|
+
form.addRow("Shows", self.preview)
|
|
2767
|
+
|
|
2768
|
+
form.addRow("Colour", _colour_button(
|
|
2769
|
+
self, lambda: (analysis.colour if analysis.colour != "auto"
|
|
2770
|
+
else analysis.scan.colour), self._set_colour))
|
|
2771
|
+
self.auto = QCheckBox("Same as scan")
|
|
2772
|
+
self.auto.setChecked(analysis.colour in (None, "", "auto"))
|
|
2773
|
+
self.auto.setToolTip("Use the scan's colour.")
|
|
2774
|
+
form.addRow("", self.auto)
|
|
2775
|
+
|
|
2776
|
+
self.note = QLabel("Adjust the values as needed. Drag the crosshairs "
|
|
2777
|
+
"to adjust the interval. Hover over a field for an "
|
|
2778
|
+
"explanation.")
|
|
2779
|
+
self.note.setWordWrap(True)
|
|
2780
|
+
self.note.setStyleSheet("color: #9a9a9a;")
|
|
2781
|
+
layout.addWidget(self.note)
|
|
2782
|
+
|
|
2783
|
+
buttons = self._buttons()
|
|
2784
|
+
layout.addWidget(buttons)
|
|
2785
|
+
|
|
2786
|
+
self.visible.toggled.connect(self._apply)
|
|
2787
|
+
self.interval.toggled.connect(self._apply)
|
|
2788
|
+
self.interval_size.changed.connect(self._apply)
|
|
2789
|
+
self.shade.toggled.connect(self._apply)
|
|
2790
|
+
self.opaque.changed.connect(self._apply)
|
|
2791
|
+
self.peak.changed.connect(self._apply)
|
|
2792
|
+
self.lines.changed.connect(self._apply)
|
|
2793
|
+
self.text_size.changed.connect(self._apply)
|
|
2794
|
+
self.flush.changed.connect(self._apply)
|
|
2795
|
+
self.label.textChanged.connect(self._apply)
|
|
2796
|
+
self.number_format.changed.connect(self._apply)
|
|
2797
|
+
self.unit.currentIndexChanged.connect(self._apply)
|
|
2798
|
+
self.auto.toggled.connect(self._apply)
|
|
2799
|
+
self.model.currentIndexChanged.connect(self._model_chosen)
|
|
2800
|
+
self.arrow_offset.valueChanged.connect(self._typed_offset)
|
|
2801
|
+
self.arrow_default.toggled.connect(self._default_offset)
|
|
2802
|
+
self.arrow_shift.valueChanged.connect(self._shift_offset)
|
|
2803
|
+
self._refresh()
|
|
2804
|
+
self.resize(460, self.sizeHint().height())
|
|
2805
|
+
|
|
2806
|
+
# ------------------------------------------------------------ showing
|
|
2807
|
+
def _unit(self):
|
|
2808
|
+
doc = self.doc
|
|
2809
|
+
return getattr(doc, "x_unit", units.TEMP_C) if doc else units.TEMP_C
|
|
2810
|
+
|
|
2811
|
+
def _refresh(self):
|
|
2812
|
+
"""Everything that follows the analysis's numbers."""
|
|
2813
|
+
analysis = self.obj
|
|
2814
|
+
self.setWindowTitle(analysis.model_name + (
|
|
2815
|
+
" (and {} more)".format(len(self.group)) if self.group else ""))
|
|
2816
|
+
self.source.setText("the file" if analysis.source == "file"
|
|
2817
|
+
else "this panel")
|
|
2818
|
+
self.label.setPlaceholderText(labels.default_template(analysis))
|
|
2819
|
+
cursors = analysis.cursors()
|
|
2820
|
+
unit = self._unit()
|
|
2821
|
+
for box, index in ((self.start, 0), (self.end, 1)):
|
|
2822
|
+
if len(cursors) == 2:
|
|
2823
|
+
box.setText("{} {}".format(
|
|
2824
|
+
numbers.write(units.from_celsius(cursors[index], unit),
|
|
2825
|
+
"%.2f"),
|
|
2826
|
+
units.TEMPERATURE_LABEL.get(unit, unit)))
|
|
2827
|
+
box.setStyleSheet("")
|
|
2828
|
+
has_interval = len(cursors) == 2 and not self.group
|
|
2829
|
+
for widget in (self.start, self.end, self.model):
|
|
2830
|
+
widget.setEnabled(has_interval)
|
|
2831
|
+
found = labels.results(analysis, self.doc)
|
|
2832
|
+
self.results.setText("<br>".join(
|
|
2833
|
+
"{}: {}".format(html.escape(name), html.escape(text))
|
|
2834
|
+
for name, text in found) or "-")
|
|
2835
|
+
self._show_lines()
|
|
2836
|
+
self._show_offset()
|
|
2837
|
+
self._show_preview()
|
|
2838
|
+
|
|
2839
|
+
def _show_lines(self):
|
|
2840
|
+
"""Lines only for an onset, endset or Tg; and, where tangents are
|
|
2841
|
+
asked for and there are none to draw, why - in one line."""
|
|
2842
|
+
analysis = self.obj
|
|
2843
|
+
self.lines.setEnabled(analysis.marks_a_point)
|
|
2844
|
+
self.lines.refresh()
|
|
2845
|
+
note = measure.lines_note(analysis, self.doc)
|
|
2846
|
+
self.lines_note.setText(note)
|
|
2847
|
+
self.lines_note.setVisible(bool(note))
|
|
2848
|
+
|
|
2849
|
+
def _show_preview(self):
|
|
2850
|
+
"""The label as it will be drawn, and anything wrong with it."""
|
|
2851
|
+
rendered = labels.render(self.obj, self.doc)
|
|
2852
|
+
lines = [markup_html(rendered.text)]
|
|
2853
|
+
for _kind, message in rendered.problems:
|
|
2854
|
+
lines.append("<span style='color:#e08030'>{}</span>".format(
|
|
2855
|
+
html.escape(message)))
|
|
2856
|
+
self.preview.setText("<br>".join(lines))
|
|
2857
|
+
if self.isVisible():
|
|
2858
|
+
self.fit()
|
|
2859
|
+
|
|
2860
|
+
def adopt_measurement(self, old_label, new_label):
|
|
2861
|
+
"""The analysis was re-measured while this was open (its gizmos were
|
|
2862
|
+
moved, or its interval typed): show the new numbers."""
|
|
2863
|
+
if new_label != old_label:
|
|
2864
|
+
if self.label.text() == (old_label or ""):
|
|
2865
|
+
self.label.blockSignals(True)
|
|
2866
|
+
self.label.setText(new_label or "")
|
|
2867
|
+
self.label.blockSignals(False)
|
|
2868
|
+
if self._snapshot.get("label") == old_label:
|
|
2869
|
+
self._snapshot["label"] = new_label
|
|
2870
|
+
self._refresh()
|
|
2871
|
+
|
|
2872
|
+
# ------------------------------------------------------------ editing
|
|
2873
|
+
def _typed_interval(self):
|
|
2874
|
+
"""Start or End typed: recompute on the new interval (the window
|
|
2875
|
+
makes it an undo step, as a gizmo drag does)."""
|
|
2876
|
+
cursors = self.obj.cursors()
|
|
2877
|
+
if len(cursors) != 2:
|
|
2878
|
+
return
|
|
2879
|
+
typed = []
|
|
2880
|
+
boxes = ((self.start, self.start) if self.one_cursor
|
|
2881
|
+
else (self.start, self.end))
|
|
2882
|
+
for box in boxes:
|
|
2883
|
+
value = units.parse_temperature(box.text(), self._unit())
|
|
2884
|
+
if value is None:
|
|
2885
|
+
box.setStyleSheet("border: 1px solid #d04040;")
|
|
2886
|
+
return
|
|
2887
|
+
typed.append(value)
|
|
2888
|
+
if all(abs(a - b) < 5e-3 for a, b in zip(typed, cursors)):
|
|
2889
|
+
return
|
|
2890
|
+
window = _window_of(self)
|
|
2891
|
+
if window is None or not window.retype_interval(self.obj, *typed):
|
|
2892
|
+
self.start.setStyleSheet("border: 1px solid #d04040;")
|
|
2893
|
+
self.end.setStyleSheet("border: 1px solid #d04040;")
|
|
2894
|
+
return
|
|
2895
|
+
self._refresh()
|
|
2896
|
+
|
|
2897
|
+
def _model_chosen(self, _index=0):
|
|
2898
|
+
name = self.model.currentData()
|
|
2899
|
+
if not name or name == self.obj.model_name:
|
|
2900
|
+
return
|
|
2901
|
+
window = _window_of(self)
|
|
2902
|
+
if window is None or not window.change_model(self.obj, name):
|
|
2903
|
+
self.model.blockSignals(True)
|
|
2904
|
+
self.model.setCurrentIndex(
|
|
2905
|
+
self.model.findData(self.obj.model_name))
|
|
2906
|
+
self.model.blockSignals(False)
|
|
2907
|
+
return
|
|
2908
|
+
self._refresh()
|
|
2909
|
+
|
|
2910
|
+
def _set_colour(self, name):
|
|
2911
|
+
self.obj.colour = name
|
|
2912
|
+
if hasattr(self, "auto"):
|
|
2913
|
+
self.auto.setChecked(False)
|
|
2914
|
+
self._live()
|
|
2915
|
+
|
|
2916
|
+
# The label's distance from the curve: stored as the screen offset
|
|
2917
|
+
# (negative is above), shown the way the y axis reads, positive up.
|
|
2918
|
+
def _effective(self, analysis):
|
|
2919
|
+
if analysis.label_dy is not None:
|
|
2920
|
+
return float(analysis.label_dy)
|
|
2921
|
+
plot = _plot_of(self)
|
|
2922
|
+
return float(plot.effective_label_dy(analysis)) if plot else -46.0
|
|
2923
|
+
|
|
2924
|
+
def _show_offset(self):
|
|
2925
|
+
boxes = (self.arrow_offset, self.arrow_default, self.arrow_shift)
|
|
2926
|
+
for box in boxes:
|
|
2927
|
+
box.blockSignals(True)
|
|
2928
|
+
self.arrow_offset.setValue(-self._effective(self.obj))
|
|
2929
|
+
self.arrow_default.setChecked(self.obj.label_dy is None)
|
|
2930
|
+
self.arrow_shift.setValue(0.0)
|
|
2931
|
+
for box in boxes:
|
|
2932
|
+
box.blockSignals(False)
|
|
2933
|
+
|
|
2934
|
+
def _typed_offset(self, value):
|
|
2935
|
+
self.obj.label_dy = -float(value)
|
|
2936
|
+
self._live()
|
|
2937
|
+
self._show_offset()
|
|
2938
|
+
|
|
2939
|
+
def _default_offset(self, on):
|
|
2940
|
+
self.obj.label_dy = None if on else self._effective(self.obj)
|
|
2941
|
+
self._live()
|
|
2942
|
+
self._show_offset()
|
|
2943
|
+
|
|
2944
|
+
def _shift_offset(self, value):
|
|
2945
|
+
"""Every analysis of the window, moved by the same amount."""
|
|
2946
|
+
if not value:
|
|
2947
|
+
return
|
|
2948
|
+
for analysis in [self.obj] + list(self.group):
|
|
2949
|
+
analysis.label_dy = self._effective(analysis) - float(value)
|
|
2950
|
+
self._last["label_dy"] = self.obj.label_dy # moved, not mirrored
|
|
2951
|
+
self._live()
|
|
2952
|
+
self._show_offset()
|
|
2953
|
+
|
|
2954
|
+
def _apply(self, *_args):
|
|
2955
|
+
analysis = self.obj
|
|
2956
|
+
analysis.visible = bool(self.visible.isChecked())
|
|
2957
|
+
analysis.show_interval = bool(self.interval.isChecked())
|
|
2958
|
+
analysis.interval_size = self.interval_size.value()
|
|
2959
|
+
analysis.shade = bool(self.shade.isChecked())
|
|
2960
|
+
analysis.shading = self.opaque.value()
|
|
2961
|
+
analysis.show_peak = self.peak.value()
|
|
2962
|
+
self.opaque.setEnabled(analysis.shade)
|
|
2963
|
+
analysis.construction = self.lines.value()
|
|
2964
|
+
analysis.label_size = self.text_size.value()
|
|
2965
|
+
analysis.flush = self.flush.value()
|
|
2966
|
+
analysis.label = self.label.text().strip() or None
|
|
2967
|
+
analysis.number_format = self.number_format.value()
|
|
2968
|
+
analysis.unit = self.unit.currentData()
|
|
2969
|
+
if self.auto.isChecked():
|
|
2970
|
+
analysis.colour = "auto"
|
|
2971
|
+
self._live()
|
|
2972
|
+
self._show_lines()
|
|
2973
|
+
self._show_preview()
|
|
2974
|
+
|
|
2975
|
+
|
|
2976
|
+
class LegendSettings(_LiveDialog):
|
|
2977
|
+
"""The key: how big, how spaced, framed or not, and where."""
|
|
2978
|
+
|
|
2979
|
+
FIELDS = ("visible", "size", "show_frame", "sample", "spacing", "colour",
|
|
2980
|
+
"x", "y", "space", "anchor", "rotation", "line_width")
|
|
2981
|
+
|
|
2982
|
+
def __init__(self, parent, legend, on_change=None):
|
|
2983
|
+
_LiveDialog.__init__(self, parent, legend, on_change)
|
|
2984
|
+
self.setWindowTitle("Legend")
|
|
2985
|
+
layout = QVBoxLayout(self)
|
|
2986
|
+
form = QFormLayout()
|
|
2987
|
+
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
2988
|
+
layout.addLayout(form)
|
|
2989
|
+
|
|
2990
|
+
self.visible = QCheckBox("Show the legend")
|
|
2991
|
+
self.visible.setChecked(bool(legend.visible))
|
|
2992
|
+
form.addRow("", self.visible)
|
|
2993
|
+
|
|
2994
|
+
self.text_size = _style_number(self, legend, "size")
|
|
2995
|
+
form.addRow("Text size", self.text_size)
|
|
2996
|
+
|
|
2997
|
+
self.sample = NumberBox()
|
|
2998
|
+
self.sample.setDecimals(0)
|
|
2999
|
+
self.sample.setRange(6.0, 80.0)
|
|
3000
|
+
self.sample.setValue(float(legend.sample))
|
|
3001
|
+
self.sample.setToolTip("Length of each colour sample.")
|
|
3002
|
+
form.addRow("Sample length", self.sample)
|
|
3003
|
+
|
|
3004
|
+
self.spacing = NumberBox()
|
|
3005
|
+
self.spacing.setDecimals(2)
|
|
3006
|
+
self.spacing.setRange(0.8, 3.0)
|
|
3007
|
+
self.spacing.setSingleStep(0.05)
|
|
3008
|
+
self.spacing.setValue(float(legend.spacing))
|
|
3009
|
+
form.addRow("Line spacing", self.spacing)
|
|
3010
|
+
self.spacing.setToolTip("Row height, in lines.")
|
|
3011
|
+
|
|
3012
|
+
self.line_width = NumberBox()
|
|
3013
|
+
self.line_width.setDecimals(2)
|
|
3014
|
+
self.line_width.setRange(0.0, 12.0)
|
|
3015
|
+
self.line_width.setSingleStep(0.25)
|
|
3016
|
+
self.line_width.setSpecialValueText("as the scans")
|
|
3017
|
+
self.line_width.setValue(float(legend.line_width or 0.0))
|
|
3018
|
+
self.line_width.setToolTip("Width of the colour samples; 0 uses "
|
|
3019
|
+
"each scan's own.")
|
|
3020
|
+
form.addRow("Line width", self.line_width)
|
|
3021
|
+
|
|
3022
|
+
self.frame = QCheckBox("Box behind it")
|
|
3023
|
+
self.frame.setToolTip("A box behind the legend, for legibility.")
|
|
3024
|
+
self.frame.setChecked(bool(legend.show_frame))
|
|
3025
|
+
form.addRow("", self.frame)
|
|
3026
|
+
|
|
3027
|
+
self.transform = ArtistTransform(legend, getattr(parent, "plot", None),
|
|
3028
|
+
on_change=self._live, parent=self)
|
|
3029
|
+
form.addRow("Place", self.transform)
|
|
3030
|
+
|
|
3031
|
+
note = QLabel("Lists the shown scans, by their labels.")
|
|
3032
|
+
note.setWordWrap(True)
|
|
3033
|
+
note.setStyleSheet("color: #9a9a9a;")
|
|
3034
|
+
layout.addWidget(note)
|
|
3035
|
+
|
|
3036
|
+
buttons = self._buttons()
|
|
3037
|
+
layout.addWidget(buttons)
|
|
3038
|
+
|
|
3039
|
+
self.visible.toggled.connect(self._apply)
|
|
3040
|
+
self.frame.toggled.connect(self._apply)
|
|
3041
|
+
for box in (self.sample, self.spacing, self.line_width):
|
|
3042
|
+
box.valueChanged.connect(self._apply)
|
|
3043
|
+
self.text_size.changed.connect(self._apply)
|
|
3044
|
+
|
|
3045
|
+
def _apply(self, *_args):
|
|
3046
|
+
legend = self.obj
|
|
3047
|
+
legend.visible = bool(self.visible.isChecked())
|
|
3048
|
+
legend.show_frame = bool(self.frame.isChecked())
|
|
3049
|
+
legend.size = self.text_size.value()
|
|
3050
|
+
legend.sample = float(self.sample.value())
|
|
3051
|
+
legend.spacing = float(self.spacing.value())
|
|
3052
|
+
legend.line_width = (float(self.line_width.value())
|
|
3053
|
+
if self.line_width.value() > 0 else None)
|
|
3054
|
+
self._live()
|
|
3055
|
+
|
|
3056
|
+
|
|
3057
|
+
class OffsetMarkerSettings(_LiveDialog):
|
|
3058
|
+
"""A y-offset marker: its size, format, colour, and where it points.
|
|
3059
|
+
|
|
3060
|
+
NOT the scan's offset: that belongs to the scan's own settings. The
|
|
3061
|
+
marker only says what it is.
|
|
3062
|
+
"""
|
|
3063
|
+
|
|
3064
|
+
FIELDS = ("size", "colour", "visible", "at", "dy", "number_format")
|
|
3065
|
+
|
|
3066
|
+
def __init__(self, parent, marker, on_change=None):
|
|
3067
|
+
_LiveDialog.__init__(self, parent, marker, on_change)
|
|
3068
|
+
self.plot = getattr(parent, "plot", None) or _plot_of(parent)
|
|
3069
|
+
self.setWindowTitle("Offset marker: {}".format(
|
|
3070
|
+
marker.scan.display_name()))
|
|
3071
|
+
layout = QVBoxLayout(self)
|
|
3072
|
+
form = QFormLayout()
|
|
3073
|
+
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
3074
|
+
layout.addLayout(form)
|
|
3075
|
+
|
|
3076
|
+
self.visible = QCheckBox("Show")
|
|
3077
|
+
self.visible.setChecked(bool(marker.visible))
|
|
3078
|
+
self.visible.setToolTip("Draw this marker.")
|
|
3079
|
+
form.addRow("", self.visible)
|
|
3080
|
+
|
|
3081
|
+
self.text_size = _style_number(self, marker, "size")
|
|
3082
|
+
form.addRow("Size", self.text_size)
|
|
3083
|
+
|
|
3084
|
+
self.number_format = _style_text(self, marker, "number_format")
|
|
3085
|
+
form.addRow("Number format", self.number_format)
|
|
3086
|
+
|
|
3087
|
+
self.at = NumberBox()
|
|
3088
|
+
self.at.setDecimals(2)
|
|
3089
|
+
self.at.setRange(-1e6, 1e6)
|
|
3090
|
+
self.at.setSuffix(" \u00b0C")
|
|
3091
|
+
self.at.setToolTip("Temperature to point at. For a group, lines "
|
|
3092
|
+
"them up.")
|
|
3093
|
+
self.at_auto = QCheckBox("Against the y axis")
|
|
3094
|
+
self.at_auto.setToolTip("Left end of the curve as shown.")
|
|
3095
|
+
form.addRow("Points at", self.at)
|
|
3096
|
+
form.addRow("", self.at_auto)
|
|
3097
|
+
|
|
3098
|
+
arrow_offset_rows(self, form)
|
|
3099
|
+
|
|
3100
|
+
form.addRow("Colour", _colour_button(
|
|
3101
|
+
self, lambda: (marker.colour if marker.colour != "auto"
|
|
3102
|
+
else "#cccccc"), self._set_colour))
|
|
3103
|
+
self.auto = QCheckBox("Follow the theme")
|
|
3104
|
+
self.auto.setChecked(marker.colour in (None, "", "auto"))
|
|
3105
|
+
self.auto.setToolTip("Light on dark, dark on light.")
|
|
3106
|
+
form.addRow("", self.auto)
|
|
3107
|
+
|
|
3108
|
+
buttons = self._buttons()
|
|
3109
|
+
layout.addWidget(buttons)
|
|
3110
|
+
self._show_place()
|
|
3111
|
+
|
|
3112
|
+
self.visible.toggled.connect(self._apply)
|
|
3113
|
+
self.text_size.changed.connect(self._apply)
|
|
3114
|
+
self.number_format.changed.connect(self._apply)
|
|
3115
|
+
self.at.valueChanged.connect(self._typed_at)
|
|
3116
|
+
self.at_auto.toggled.connect(self._auto_at)
|
|
3117
|
+
self.arrow_offset.valueChanged.connect(self._typed_offset)
|
|
3118
|
+
self.arrow_default.toggled.connect(self._default_offset)
|
|
3119
|
+
self.arrow_shift.valueChanged.connect(self._shift_offset)
|
|
3120
|
+
self.auto.toggled.connect(self._apply)
|
|
3121
|
+
|
|
3122
|
+
# The marker stores how far BELOW the curve its text starts; the
|
|
3123
|
+
# window says it the way the y axis does, positive up.
|
|
3124
|
+
def _effective(self, marker):
|
|
3125
|
+
if marker.dy is not None:
|
|
3126
|
+
return float(marker.dy)
|
|
3127
|
+
return float(self.plot.marker_dy(marker)) if self.plot else 0.0
|
|
3128
|
+
|
|
3129
|
+
def _show_place(self):
|
|
3130
|
+
"""The numbers as drawn, whether chosen or automatic."""
|
|
3131
|
+
marker = self.obj
|
|
3132
|
+
widgets = (self.at, self.at_auto, self.arrow_offset,
|
|
3133
|
+
self.arrow_default, self.arrow_shift)
|
|
3134
|
+
for widget in widgets:
|
|
3135
|
+
widget.blockSignals(True)
|
|
3136
|
+
at = self.plot.marker_celsius(marker) if self.plot else None
|
|
3137
|
+
if at is None and marker.at and marker.at[0] == "T":
|
|
3138
|
+
at = marker.at[1]
|
|
3139
|
+
self.at.setValue(float(at or 0.0))
|
|
3140
|
+
self.at_auto.setChecked(marker.at is None)
|
|
3141
|
+
self.arrow_offset.setValue(-self._effective(marker))
|
|
3142
|
+
self.arrow_default.setChecked(marker.dy is None)
|
|
3143
|
+
self.arrow_shift.setValue(0.0)
|
|
3144
|
+
for widget in widgets:
|
|
3145
|
+
widget.blockSignals(False)
|
|
3146
|
+
|
|
3147
|
+
def _typed_at(self, value):
|
|
3148
|
+
self.obj.at = ("T", float(value))
|
|
3149
|
+
self._apply()
|
|
3150
|
+
|
|
3151
|
+
def _auto_at(self, on):
|
|
3152
|
+
if on:
|
|
3153
|
+
self.obj.at = None
|
|
3154
|
+
elif self.obj.at is None:
|
|
3155
|
+
self.obj.at = ("T", float(self.at.value()))
|
|
3156
|
+
self._apply()
|
|
3157
|
+
|
|
3158
|
+
def _typed_offset(self, value):
|
|
3159
|
+
self.obj.dy = -float(value)
|
|
3160
|
+
self._apply()
|
|
3161
|
+
|
|
3162
|
+
def _default_offset(self, on):
|
|
3163
|
+
self.obj.dy = None if on else self._effective(self.obj)
|
|
3164
|
+
self._apply()
|
|
3165
|
+
|
|
3166
|
+
def _shift_offset(self, value):
|
|
3167
|
+
"""Every marker of the window, moved by the same amount - not set
|
|
3168
|
+
to the same value, so a group keeps its differences."""
|
|
3169
|
+
if not value:
|
|
3170
|
+
return
|
|
3171
|
+
for marker in [self.obj] + list(self.group):
|
|
3172
|
+
marker.dy = self._effective(marker) - float(value)
|
|
3173
|
+
self._last["dy"] = self.obj.dy # moved, not to be mirrored
|
|
3174
|
+
self._apply()
|
|
3175
|
+
|
|
3176
|
+
def _set_colour(self, name):
|
|
3177
|
+
self.obj.colour = name
|
|
3178
|
+
if hasattr(self, "auto"):
|
|
3179
|
+
self.auto.setChecked(False)
|
|
3180
|
+
self._live()
|
|
3181
|
+
|
|
3182
|
+
def _apply(self, *_args):
|
|
3183
|
+
marker = self.obj
|
|
3184
|
+
marker.visible = bool(self.visible.isChecked())
|
|
3185
|
+
marker.size = self.text_size.value()
|
|
3186
|
+
marker.number_format = self.number_format.value()
|
|
3187
|
+
if self.auto.isChecked():
|
|
3188
|
+
marker.colour = "auto"
|
|
3189
|
+
self._live()
|
|
3190
|
+
self._show_place()
|
|
3191
|
+
|
|
3192
|
+
|
|
3193
|
+
class ArrowSettings(_LiveDialog):
|
|
3194
|
+
"""The heat-flow arrow: what it says, and therefore what the axis does,
|
|
3195
|
+
and its shape in points.
|
|
3196
|
+
|
|
3197
|
+
The head's length, width and tip angle are tied - any two decide the
|
|
3198
|
+
third - so one of the angle and the width can be LOCKED: with the angle
|
|
3199
|
+
locked, a longer head is also a wider one; with the width locked, a
|
|
3200
|
+
longer head is a sharper one. Unlocked, the length and the width are
|
|
3201
|
+
kept and the angle is what follows.
|
|
3202
|
+
"""
|
|
3203
|
+
|
|
3204
|
+
FIELDS = ("word", "direction", "colour", "visible",
|
|
3205
|
+
"x", "y", "space", "anchor", "head_length", "head_width",
|
|
3206
|
+
"tail_length", "tail_width", "lock", "size")
|
|
3207
|
+
|
|
3208
|
+
def __init__(self, parent, arrow, on_change=None):
|
|
3209
|
+
_LiveDialog.__init__(self, parent, arrow, on_change)
|
|
3210
|
+
self.setWindowTitle("Heat-flow arrow")
|
|
3211
|
+
layout = QVBoxLayout(self)
|
|
3212
|
+
form = QFormLayout()
|
|
3213
|
+
layout.addLayout(form)
|
|
3214
|
+
|
|
3215
|
+
row = QWidget(self)
|
|
3216
|
+
row_layout = QHBoxLayout(row)
|
|
3217
|
+
row_layout.setContentsMargins(0, 0, 0, 0)
|
|
3218
|
+
self.word = QComboBox()
|
|
3219
|
+
for value in units.ARROW_WORDS:
|
|
3220
|
+
self.word.addItem(value.capitalize(), value)
|
|
3221
|
+
self.word.setCurrentIndex(units.ARROW_WORDS.index(arrow.word))
|
|
3222
|
+
self.direction = QComboBox()
|
|
3223
|
+
for value in units.ARROW_DIRECTIONS:
|
|
3224
|
+
self.direction.addItem(value.capitalize(), value)
|
|
3225
|
+
self.direction.setCurrentIndex(
|
|
3226
|
+
units.ARROW_DIRECTIONS.index(arrow.direction))
|
|
3227
|
+
row_layout.addWidget(self.word)
|
|
3228
|
+
row_layout.addWidget(self.direction)
|
|
3229
|
+
form.addRow("Label", row)
|
|
3230
|
+
|
|
3231
|
+
self.text_size = _style_number(self, arrow, "size")
|
|
3232
|
+
form.addRow("Text size", self.text_size)
|
|
3233
|
+
|
|
3234
|
+
self.transform = ArtistTransform(arrow, getattr(parent, "plot", None),
|
|
3235
|
+
on_change=self._live, parent=self)
|
|
3236
|
+
form.addRow("Place", self.transform)
|
|
3237
|
+
|
|
3238
|
+
def points(low, high, decimals=1, suffix=" pt"):
|
|
3239
|
+
box = NumberBox()
|
|
3240
|
+
box.setDecimals(decimals)
|
|
3241
|
+
box.setRange(low, high)
|
|
3242
|
+
box.setSingleStep(0.5)
|
|
3243
|
+
box.setSuffix(suffix)
|
|
3244
|
+
return box
|
|
3245
|
+
|
|
3246
|
+
self.head_length = points(0.5, 200.0)
|
|
3247
|
+
self.head_width = points(0.5, 200.0)
|
|
3248
|
+
self.tip_angle = points(5.0, 170.0, suffix=" \u00b0")
|
|
3249
|
+
self.tail_length = points(0.0, 400.0)
|
|
3250
|
+
self.tail_width = points(0.5, 200.0)
|
|
3251
|
+
form.addRow("Head length", self.head_length)
|
|
3252
|
+
form.addRow("Head width", self.head_width)
|
|
3253
|
+
form.addRow("Tip angle", self.tip_angle)
|
|
3254
|
+
self.lock_angle = QCheckBox("Lock the tip angle")
|
|
3255
|
+
self.lock_width = QCheckBox("Lock the head width")
|
|
3256
|
+
locks = QWidget(self)
|
|
3257
|
+
locks_row = QHBoxLayout(locks)
|
|
3258
|
+
locks_row.setContentsMargins(0, 0, 0, 0)
|
|
3259
|
+
locks_row.addWidget(self.lock_angle)
|
|
3260
|
+
locks_row.addWidget(self.lock_width)
|
|
3261
|
+
form.addRow("", locks)
|
|
3262
|
+
form.addRow("Tail length", self.tail_length)
|
|
3263
|
+
form.addRow("Tail width", self.tail_width)
|
|
3264
|
+
self.head_length.setToolTip("Head length, pt.")
|
|
3265
|
+
self.head_width.setToolTip("Head width, pt. Tied to length and "
|
|
3266
|
+
"angle.")
|
|
3267
|
+
self.tip_angle.setToolTip("Angle at the point. Tied to length and "
|
|
3268
|
+
"width.")
|
|
3269
|
+
self.lock_angle.setToolTip("Keep the angle when the head is "
|
|
3270
|
+
"resized.")
|
|
3271
|
+
self.lock_width.setToolTip("Keep the width when the head is "
|
|
3272
|
+
"resized.")
|
|
3273
|
+
self.tail_length.setToolTip("Tail length, pt.")
|
|
3274
|
+
self.tail_width.setToolTip("Tail width, pt.")
|
|
3275
|
+
self.word.setToolTip("Exo or endo; the curves follow the label.")
|
|
3276
|
+
self.direction.setToolTip("Up or down; the curves follow the label.")
|
|
3277
|
+
self._show_shape()
|
|
3278
|
+
|
|
3279
|
+
form.addRow("Colour", _colour_button(
|
|
3280
|
+
self, lambda: arrow.colour, self._set_colour))
|
|
3281
|
+
|
|
3282
|
+
self.auto = QCheckBox("Follow the theme")
|
|
3283
|
+
self.auto.setChecked(arrow.colour in (None, "", "auto"))
|
|
3284
|
+
self.auto.setToolTip("Light on dark, dark on light.")
|
|
3285
|
+
form.addRow("", self.auto)
|
|
3286
|
+
|
|
3287
|
+
self.visible = QCheckBox("Show the arrow")
|
|
3288
|
+
self.visible.setChecked(bool(arrow.visible))
|
|
3289
|
+
form.addRow("", self.visible)
|
|
3290
|
+
|
|
3291
|
+
self.note = QLabel("")
|
|
3292
|
+
self.note.setWordWrap(True)
|
|
3293
|
+
self.note.setStyleSheet("color: #9a9a9a;")
|
|
3294
|
+
layout.addWidget(self.note)
|
|
3295
|
+
|
|
3296
|
+
buttons = self._buttons()
|
|
3297
|
+
layout.addWidget(buttons)
|
|
3298
|
+
|
|
3299
|
+
self.word.currentIndexChanged.connect(self._apply)
|
|
3300
|
+
self.direction.currentIndexChanged.connect(self._apply)
|
|
3301
|
+
self.text_size.changed.connect(self._apply)
|
|
3302
|
+
self.visible.toggled.connect(self._apply)
|
|
3303
|
+
self.auto.toggled.connect(self._apply)
|
|
3304
|
+
self.head_length.valueChanged.connect(
|
|
3305
|
+
lambda v: self._head(self.obj.head_for_length(v)))
|
|
3306
|
+
self.head_width.valueChanged.connect(
|
|
3307
|
+
lambda v: self._head(self.obj.head_for_width(v)))
|
|
3308
|
+
self.tip_angle.valueChanged.connect(
|
|
3309
|
+
lambda v: self._head(self.obj.head_for_angle(v)))
|
|
3310
|
+
self.tail_length.valueChanged.connect(self._apply)
|
|
3311
|
+
self.tail_width.valueChanged.connect(self._apply)
|
|
3312
|
+
self.lock_angle.toggled.connect(
|
|
3313
|
+
lambda on: self._lock("angle", on))
|
|
3314
|
+
self.lock_width.toggled.connect(
|
|
3315
|
+
lambda on: self._lock("width", on))
|
|
3316
|
+
self._describe()
|
|
3317
|
+
|
|
3318
|
+
def _show_shape(self):
|
|
3319
|
+
"""Every dimension as it now is; the locked one greyed out."""
|
|
3320
|
+
arrow = self.obj
|
|
3321
|
+
boxes = (self.head_length, self.head_width, self.tip_angle,
|
|
3322
|
+
self.tail_length, self.tail_width, self.lock_angle,
|
|
3323
|
+
self.lock_width)
|
|
3324
|
+
for box in boxes:
|
|
3325
|
+
box.blockSignals(True)
|
|
3326
|
+
self.head_length.setValue(float(arrow.head_length))
|
|
3327
|
+
self.head_width.setValue(float(arrow.head_width))
|
|
3328
|
+
self.tip_angle.setValue(float(arrow.tip_angle))
|
|
3329
|
+
self.tail_length.setValue(float(arrow.tail_length))
|
|
3330
|
+
self.tail_width.setValue(float(arrow.tail_width))
|
|
3331
|
+
self.lock_angle.setChecked(arrow.lock == "angle")
|
|
3332
|
+
self.lock_width.setChecked(arrow.lock == "width")
|
|
3333
|
+
self.tip_angle.setEnabled(arrow.lock != "angle")
|
|
3334
|
+
self.head_width.setEnabled(arrow.lock != "width")
|
|
3335
|
+
for box in boxes:
|
|
3336
|
+
box.blockSignals(False)
|
|
3337
|
+
|
|
3338
|
+
def _head(self, head):
|
|
3339
|
+
self.obj.head_length, self.obj.head_width = head
|
|
3340
|
+
self._show_shape()
|
|
3341
|
+
self._live()
|
|
3342
|
+
|
|
3343
|
+
def _lock(self, which, on):
|
|
3344
|
+
"""One lock at a time: ticking one unticks the other."""
|
|
3345
|
+
if on:
|
|
3346
|
+
self.obj.lock = which
|
|
3347
|
+
elif self.obj.lock == which:
|
|
3348
|
+
self.obj.lock = None
|
|
3349
|
+
self._show_shape()
|
|
3350
|
+
self._live()
|
|
3351
|
+
|
|
3352
|
+
def _set_colour(self, name):
|
|
3353
|
+
self.obj.colour = name
|
|
3354
|
+
if hasattr(self, "auto"):
|
|
3355
|
+
self.auto.setChecked(False)
|
|
3356
|
+
self._live()
|
|
3357
|
+
|
|
3358
|
+
def _describe(self):
|
|
3359
|
+
self.note.setText(
|
|
3360
|
+
"\"{} {}\": exotherms point {}; the curves follow the label. "
|
|
3361
|
+
"Sizes in pt. S scales the whole arrow.".format(
|
|
3362
|
+
self.obj.word, self.obj.direction, self.obj.orientation))
|
|
3363
|
+
|
|
3364
|
+
def _apply(self, *_args):
|
|
3365
|
+
arrow = self.obj
|
|
3366
|
+
arrow.word = self.word.currentData()
|
|
3367
|
+
arrow.direction = self.direction.currentData()
|
|
3368
|
+
arrow.size = self.text_size.value()
|
|
3369
|
+
arrow.tail_length = float(self.tail_length.value())
|
|
3370
|
+
arrow.tail_width = float(self.tail_width.value())
|
|
3371
|
+
arrow.visible = bool(self.visible.isChecked())
|
|
3372
|
+
if self.auto.isChecked():
|
|
3373
|
+
arrow.colour = "auto"
|
|
3374
|
+
self._describe()
|
|
3375
|
+
self._live()
|
|
3376
|
+
|
|
3377
|
+
|
|
3378
|
+
class ExportDialog(QDialog):
|
|
3379
|
+
"""Where the figure goes, and in which colours.
|
|
3380
|
+
|
|
3381
|
+
The system's save dialog has no room for a choice of its own, so this
|
|
3382
|
+
is the export: a file (Browse opens the system dialog for it), and the
|
|
3383
|
+
colours - light, for a page, or as the theme on screen.
|
|
3384
|
+
"""
|
|
3385
|
+
|
|
3386
|
+
def __init__(self, parent, path, light=True, size_text=""):
|
|
3387
|
+
QDialog.__init__(self, parent)
|
|
3388
|
+
self.setWindowTitle("Export the figure")
|
|
3389
|
+
layout = QVBoxLayout(self)
|
|
3390
|
+
form = QFormLayout()
|
|
3391
|
+
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
3392
|
+
layout.addLayout(form)
|
|
3393
|
+
|
|
3394
|
+
row = QWidget(self)
|
|
3395
|
+
line = QHBoxLayout(row)
|
|
3396
|
+
line.setContentsMargins(0, 0, 0, 0)
|
|
3397
|
+
self.path_edit = QLineEdit(path, self)
|
|
3398
|
+
self.path_edit.setMinimumWidth(320)
|
|
3399
|
+
self.path_edit.setToolTip("A .svg or a .png file.")
|
|
3400
|
+
browse = QPushButton("Browse...", self)
|
|
3401
|
+
browse.setAutoDefault(False)
|
|
3402
|
+
browse.clicked.connect(lambda _c=False: self._browse())
|
|
3403
|
+
line.addWidget(self.path_edit, 1)
|
|
3404
|
+
line.addWidget(browse, 0)
|
|
3405
|
+
form.addRow("File", row)
|
|
3406
|
+
|
|
3407
|
+
self.colours = QComboBox(self)
|
|
3408
|
+
self.colours.addItem("Light, for a page", True)
|
|
3409
|
+
self.colours.addItem("Same as the theme", False)
|
|
3410
|
+
self.colours.setCurrentIndex(0 if light else 1)
|
|
3411
|
+
self.colours.setToolTip("Light is dark ink on white; the theme is "
|
|
3412
|
+
"what the screen shows.")
|
|
3413
|
+
form.addRow("Colours", self.colours)
|
|
3414
|
+
if size_text:
|
|
3415
|
+
form.addRow("Size", QLabel(size_text, self))
|
|
3416
|
+
|
|
3417
|
+
buttons = QDialogButtonBox(
|
|
3418
|
+
QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
|
|
3419
|
+
buttons.button(QDialogButtonBox.Ok).setText("Export")
|
|
3420
|
+
buttons.accepted.connect(self.accept)
|
|
3421
|
+
buttons.rejected.connect(self.reject)
|
|
3422
|
+
layout.addWidget(buttons)
|
|
3423
|
+
|
|
3424
|
+
def _browse(self):
|
|
3425
|
+
from PySide6.QtWidgets import QFileDialog
|
|
3426
|
+
path, _f = QFileDialog.getSaveFileName(
|
|
3427
|
+
self, "Export the figure", self.path_edit.text(),
|
|
3428
|
+
"SVG image (*.svg);;PNG image (*.png)")
|
|
3429
|
+
if path:
|
|
3430
|
+
self.path_edit.setText(path)
|
|
3431
|
+
|
|
3432
|
+
def values(self):
|
|
3433
|
+
"""`(path, light)`, or None without a file name."""
|
|
3434
|
+
path = self.path_edit.text().strip()
|
|
3435
|
+
if not path:
|
|
3436
|
+
return None
|
|
3437
|
+
if os.path.splitext(path)[1].lower() not in (".svg", ".png"):
|
|
3438
|
+
path += ".svg"
|
|
3439
|
+
return path, bool(self.colours.currentData())
|
|
3440
|
+
|
|
3441
|
+
|
|
3442
|
+
def install_basic_colours():
|
|
3443
|
+
"""The colour picker's 48 basic colours, read left to right and top to
|
|
3444
|
+
bottom: the DSC_Plotter template's first, in its order. Qt numbers
|
|
3445
|
+
the grid down its six rows first, so the reading order is mapped onto
|
|
3446
|
+
that."""
|
|
3447
|
+
colours = (PLOTTER_COLOURS + MORE_COLOURS)[:48]
|
|
3448
|
+
for order, name in enumerate(colours):
|
|
3449
|
+
row, column = divmod(order, 8)
|
|
3450
|
+
QColorDialog.setStandardColor(row + column * 6, QColor(name))
|
|
3451
|
+
return len(colours)
|
|
3452
|
+
|
|
3453
|
+
|
|
3454
|
+
class ImageSettings(_LiveDialog):
|
|
3455
|
+
"""A picture on the figure: shown or not, its width, where it sits."""
|
|
3456
|
+
|
|
3457
|
+
FIELDS = ("width", "visible", "x", "y", "space", "anchor", "rotation")
|
|
3458
|
+
|
|
3459
|
+
def __init__(self, parent, image, on_change=None):
|
|
3460
|
+
_LiveDialog.__init__(self, parent, image, on_change)
|
|
3461
|
+
self.setWindowTitle("Image")
|
|
3462
|
+
layout = QVBoxLayout(self)
|
|
3463
|
+
form = QFormLayout()
|
|
3464
|
+
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
3465
|
+
layout.addLayout(form)
|
|
3466
|
+
|
|
3467
|
+
self.shown = QCheckBox("Show")
|
|
3468
|
+
self.shown.setChecked(bool(image.visible))
|
|
3469
|
+
self.shown.setToolTip("Draw the image.")
|
|
3470
|
+
form.addRow("", self.shown)
|
|
3471
|
+
|
|
3472
|
+
self.picture_width = NumberBox()
|
|
3473
|
+
self.picture_width.setDecimals(1)
|
|
3474
|
+
self.picture_width.setRange(4.0, 4000.0)
|
|
3475
|
+
self.picture_width.setSuffix(" px")
|
|
3476
|
+
self.picture_width.setValue(float(image.width))
|
|
3477
|
+
self.picture_width.setToolTip("Drawn width; the height keeps the "
|
|
3478
|
+
"picture's proportions. S scales it "
|
|
3479
|
+
"by hand.")
|
|
3480
|
+
form.addRow("Width", self.picture_width)
|
|
3481
|
+
|
|
3482
|
+
self.transform = ArtistTransform(image, getattr(parent, "plot", None),
|
|
3483
|
+
on_change=self._live, parent=self)
|
|
3484
|
+
form.addRow("Place", self.transform)
|
|
3485
|
+
|
|
3486
|
+
buttons = self._buttons()
|
|
3487
|
+
layout.addWidget(buttons)
|
|
3488
|
+
self.shown.toggled.connect(self._apply)
|
|
3489
|
+
self.picture_width.valueChanged.connect(self._apply)
|
|
3490
|
+
|
|
3491
|
+
def _apply(self, *_args):
|
|
3492
|
+
self.obj.visible = bool(self.shown.isChecked())
|
|
3493
|
+
self.obj.width = float(self.picture_width.value())
|
|
3494
|
+
self._live()
|
|
3495
|
+
|
|
3496
|
+
|
|
3497
|
+
class MoleculeSettings(_LiveDialog):
|
|
3498
|
+
"""A skeletal structure: its SMILES, its bonds and labels, its colour,
|
|
3499
|
+
and where it sits. The sizes start at the ACS 1996 document style's."""
|
|
3500
|
+
|
|
3501
|
+
FIELDS = ("visible", "colour", "bond_length", "bond_width", "label_size",
|
|
3502
|
+
"upright_labels", "x", "y", "space", "anchor", "rotation",
|
|
3503
|
+
"smiles", "atoms", "bonds", "label_font", "colour_by_element")
|
|
3504
|
+
INDIVIDUAL = ("x", "y", "space", "smiles", "atoms", "bonds")
|
|
3505
|
+
GROUP_DISABLED = ("smiles_edit", "transform.space", "transform.at_x",
|
|
3506
|
+
"transform.at_y")
|
|
3507
|
+
|
|
3508
|
+
def __init__(self, parent, molecule, on_change=None):
|
|
3509
|
+
_LiveDialog.__init__(self, parent, molecule, on_change)
|
|
3510
|
+
self.setWindowTitle("Structure")
|
|
3511
|
+
layout = QVBoxLayout(self)
|
|
3512
|
+
form = QFormLayout()
|
|
3513
|
+
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
3514
|
+
layout.addLayout(form)
|
|
3515
|
+
|
|
3516
|
+
self.shown = QCheckBox("Show")
|
|
3517
|
+
self.shown.setChecked(bool(molecule.visible))
|
|
3518
|
+
self.shown.setToolTip("Draw the structure.")
|
|
3519
|
+
form.addRow("", self.shown)
|
|
3520
|
+
|
|
3521
|
+
self.smiles_edit = QLineEdit(molecule.smiles, self)
|
|
3522
|
+
self.smiles_edit.setToolTip("Type another SMILES and press Enter to "
|
|
3523
|
+
"redraw it (needs RDKit).")
|
|
3524
|
+
form.addRow("SMILES", self.smiles_edit)
|
|
3525
|
+
|
|
3526
|
+
def size(low, high, step, suffix):
|
|
3527
|
+
box = NumberBox()
|
|
3528
|
+
box.setDecimals(2)
|
|
3529
|
+
box.setRange(low, high)
|
|
3530
|
+
box.setSingleStep(step)
|
|
3531
|
+
box.setSuffix(suffix)
|
|
3532
|
+
return box
|
|
3533
|
+
|
|
3534
|
+
self.bond_length = size(2.0, 400.0, 1.0, " px")
|
|
3535
|
+
self.bond_length.setValue(float(molecule.bond_length))
|
|
3536
|
+
self.bond_length.setToolTip("Bond length; ACS style is 19.2 px "
|
|
3537
|
+
"(0.2 inch).")
|
|
3538
|
+
form.addRow("Bond length", self.bond_length)
|
|
3539
|
+
self.bond_width = size(0.1, 20.0, 0.1, " px")
|
|
3540
|
+
self.bond_width.setValue(float(molecule.bond_width))
|
|
3541
|
+
self.bond_width.setToolTip("Line width of the bonds; ACS style is "
|
|
3542
|
+
"0.8 px (0.6 pt).")
|
|
3543
|
+
form.addRow("Bond width", self.bond_width)
|
|
3544
|
+
self.label_size = size(3.0, 72.0, 0.5, " pt")
|
|
3545
|
+
self.label_size.setValue(float(molecule.label_size))
|
|
3546
|
+
self.label_size.setToolTip("Size of the element labels.")
|
|
3547
|
+
form.addRow("Label size", self.label_size)
|
|
3548
|
+
|
|
3549
|
+
self.label_font = FontChoice(
|
|
3550
|
+
molecule.label_font,
|
|
3551
|
+
lambda: (style.inherited(self.doc, molecule, "label_font")
|
|
3552
|
+
or style.figure_value(self.doc, "font_family") or ""),
|
|
3553
|
+
parent=self)
|
|
3554
|
+
self.label_font.setToolTip("The element labels' typeface; Default "
|
|
3555
|
+
"is the house style's (Settings, "
|
|
3556
|
+
"Structure labels).")
|
|
3557
|
+
form.addRow("Label font", self.label_font)
|
|
3558
|
+
|
|
3559
|
+
self.by_element = QCheckBox("Colour by element")
|
|
3560
|
+
self.by_element.setChecked(bool(molecule.colour_by_element))
|
|
3561
|
+
self.by_element.setToolTip("N blue, O red, S yellow...; the bonds "
|
|
3562
|
+
"keep the colour below.")
|
|
3563
|
+
form.addRow("", self.by_element)
|
|
3564
|
+
|
|
3565
|
+
self.upright = QCheckBox("Labels stay upright when rotated")
|
|
3566
|
+
self.upright.setChecked(bool(molecule.upright_labels))
|
|
3567
|
+
self.upright.setToolTip("Off: the labels turn with the structure.")
|
|
3568
|
+
form.addRow("", self.upright)
|
|
3569
|
+
|
|
3570
|
+
form.addRow("Colour", _colour_button(
|
|
3571
|
+
self, lambda: (molecule.colour if molecule.colour != "auto"
|
|
3572
|
+
else "#cccccc"), self._set_colour))
|
|
3573
|
+
self.auto = QCheckBox("Follow the theme")
|
|
3574
|
+
self.auto.setChecked(molecule.colour in (None, "", "auto"))
|
|
3575
|
+
self.auto.setToolTip("Light on dark, dark on light.")
|
|
3576
|
+
form.addRow("", self.auto)
|
|
3577
|
+
|
|
3578
|
+
self.transform = ArtistTransform(molecule,
|
|
3579
|
+
getattr(parent, "plot", None),
|
|
3580
|
+
on_change=self._live, parent=self)
|
|
3581
|
+
form.addRow("Place", self.transform)
|
|
3582
|
+
|
|
3583
|
+
buttons = self._buttons()
|
|
3584
|
+
layout.addWidget(buttons)
|
|
3585
|
+
self.shown.toggled.connect(self._apply)
|
|
3586
|
+
for box in (self.bond_length, self.bond_width, self.label_size):
|
|
3587
|
+
box.valueChanged.connect(self._apply)
|
|
3588
|
+
self.upright.toggled.connect(self._apply)
|
|
3589
|
+
self.by_element.toggled.connect(self._apply)
|
|
3590
|
+
self.label_font.changed.connect(self._apply)
|
|
3591
|
+
self.auto.toggled.connect(self._apply)
|
|
3592
|
+
self.smiles_edit.editingFinished.connect(self._new_smiles)
|
|
3593
|
+
|
|
3594
|
+
def _new_smiles(self):
|
|
3595
|
+
from ..core import chem
|
|
3596
|
+
text = self.smiles_edit.text().strip()
|
|
3597
|
+
if text == self.obj.smiles:
|
|
3598
|
+
return
|
|
3599
|
+
drawing = chem.layout(text)
|
|
3600
|
+
if drawing is None:
|
|
3601
|
+
self.smiles_edit.setStyleSheet("border: 1px solid #d04040;")
|
|
3602
|
+
return
|
|
3603
|
+
self.smiles_edit.setStyleSheet("")
|
|
3604
|
+
self.obj.smiles = text
|
|
3605
|
+
self.obj.atoms = drawing["atoms"]
|
|
3606
|
+
self.obj.bonds = drawing["bonds"]
|
|
3607
|
+
self._live()
|
|
3608
|
+
|
|
3609
|
+
def _set_colour(self, name):
|
|
3610
|
+
self.obj.colour = name
|
|
3611
|
+
if hasattr(self, "auto"):
|
|
3612
|
+
self.auto.setChecked(False)
|
|
3613
|
+
self._live()
|
|
3614
|
+
|
|
3615
|
+
def _apply(self, *_args):
|
|
3616
|
+
molecule = self.obj
|
|
3617
|
+
molecule.visible = bool(self.shown.isChecked())
|
|
3618
|
+
molecule.bond_length = float(self.bond_length.value())
|
|
3619
|
+
molecule.bond_width = float(self.bond_width.value())
|
|
3620
|
+
molecule.label_size = float(self.label_size.value())
|
|
3621
|
+
molecule.upright_labels = bool(self.upright.isChecked())
|
|
3622
|
+
molecule.colour_by_element = bool(self.by_element.isChecked())
|
|
3623
|
+
molecule.label_font = self.label_font.value() or None
|
|
3624
|
+
if self.auto.isChecked():
|
|
3625
|
+
molecule.colour = "auto"
|
|
3626
|
+
self._live()
|
|
3627
|
+
|
|
3628
|
+
|
|
3629
|
+
# The windows of objects with a place in the figure's stack show it: a
|
|
3630
|
+
# Layer field (`_LiveDialog._layer_row`).
|
|
3631
|
+
for _kind in (ScanSettings, AnalysisSettings, LabelSettings, LegendSettings,
|
|
3632
|
+
ArrowSettings, OffsetMarkerSettings, ImageSettings,
|
|
3633
|
+
MoleculeSettings):
|
|
3634
|
+
_kind.LAYERED = True
|
|
3635
|
+
|
|
3636
|
+
# Qt calls the handlers here by itself; an error in one is logged and
|
|
3637
|
+
# survived rather than the end of the program (`core/log.py`).
|
|
3638
|
+
from ..core import log as _log
|
|
3639
|
+
_log.guard_classes(globals(), __name__)
|