triplot 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dscpanel/ui/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__)