triplot 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dscpanel/__init__.py +4 -0
- dscpanel/__main__.py +155 -0
- dscpanel/branding.py +142 -0
- dscpanel/core/__init__.py +0 -0
- dscpanel/core/arrange.py +143 -0
- dscpanel/core/chem.py +252 -0
- dscpanel/core/dtg.py +135 -0
- dscpanel/core/export.py +649 -0
- dscpanel/core/figure.py +171 -0
- dscpanel/core/labels.py +430 -0
- dscpanel/core/loader.py +202 -0
- dscpanel/core/log.py +147 -0
- dscpanel/core/measure.py +636 -0
- dscpanel/core/model.py +1993 -0
- dscpanel/core/molar.py +344 -0
- dscpanel/core/numbers.py +208 -0
- dscpanel/core/ops.py +161 -0
- dscpanel/core/presets.py +312 -0
- dscpanel/core/profile.py +15 -0
- dscpanel/core/session.py +667 -0
- dscpanel/core/shades.py +54 -0
- dscpanel/core/style.py +528 -0
- dscpanel/core/trios_analysis.py +636 -0
- dscpanel/core/trios_io.py +1311 -0
- dscpanel/core/undo.py +230 -0
- dscpanel/core/units.py +220 -0
- dscpanel/register.py +284 -0
- dscpanel/ui/__init__.py +0 -0
- dscpanel/ui/appearance.py +146 -0
- dscpanel/ui/colour.py +629 -0
- dscpanel/ui/dialogs.py +3639 -0
- dscpanel/ui/loading.py +95 -0
- dscpanel/ui/numbox.py +103 -0
- dscpanel/ui/outliner.py +818 -0
- dscpanel/ui/palette.py +193 -0
- dscpanel/ui/plot.py +8349 -0
- dscpanel/ui/settings.py +256 -0
- dscpanel/ui/window.py +4129 -0
- triplot-1.1.0.dist-info/METADATA +315 -0
- triplot-1.1.0.dist-info/RECORD +44 -0
- triplot-1.1.0.dist-info/WHEEL +5 -0
- triplot-1.1.0.dist-info/entry_points.txt +5 -0
- triplot-1.1.0.dist-info/licenses/LICENSE +22 -0
- triplot-1.1.0.dist-info/top_level.txt +1 -0
dscpanel/ui/outliner.py
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
"""The outliner: every object in the figure, and what state it is in.
|
|
2
|
+
|
|
3
|
+
Once scans can be hidden, dragged anywhere and given a molar mass one at a
|
|
4
|
+
time, the plot alone stops being able to say what is in the figure - a hidden
|
|
5
|
+
scan is invisible by definition, and "which of these eight is still waiting
|
|
6
|
+
for its M" is not a question a curve can answer. So there is a list, as in
|
|
7
|
+
Blender.
|
|
8
|
+
|
|
9
|
+
It is a TREE because the data is one: a sample is a file with a mass, a molar
|
|
10
|
+
mass and an exotherm direction, and its scans are the segments of it. Putting
|
|
11
|
+
the molar mass on the sample row rather than on every scan is the difference
|
|
12
|
+
between typing it once and typing it seven times.
|
|
13
|
+
|
|
14
|
+
Selection is shared with the plot in both directions: clicking a row selects
|
|
15
|
+
the curve, and clicking a curve highlights the row.
|
|
16
|
+
|
|
17
|
+
Two parts, with a line between them: the DATA on top -
|
|
18
|
+
each file, its segments, and under each scan what belongs to that trace (its
|
|
19
|
+
analyses, its own labels, its offset marker while the markers are shown) -
|
|
20
|
+
and below the line the DECORATORS, everything drawn on the figure that
|
|
21
|
+
belongs to no trace: the heat-flow arrow, the legend, free labels, pictures
|
|
22
|
+
and structures.
|
|
23
|
+
|
|
24
|
+
A label row can be DRAGGED onto a scan (or anything under one) to give the
|
|
25
|
+
label to that scan - parenting - and onto the Decorators to free
|
|
26
|
+
it again. The window does the work (`MainWindow.parent_labels`).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from PySide6.QtCore import QPointF, Qt, QTimer, Signal
|
|
30
|
+
from PySide6.QtGui import QBrush, QColor, QFont, QPainter, QPalette, QPen
|
|
31
|
+
from PySide6.QtWidgets import (QAbstractItemView, QHeaderView, QStyle,
|
|
32
|
+
QStyledItemDelegate, QTreeWidget,
|
|
33
|
+
QTreeWidgetItem)
|
|
34
|
+
|
|
35
|
+
from ..core import model
|
|
36
|
+
from . import plot as plot_module
|
|
37
|
+
|
|
38
|
+
_ALARM = QColor(232, 76, 76)
|
|
39
|
+
_DIM = QColor(150, 150, 150)
|
|
40
|
+
|
|
41
|
+
#: The keys of the two rows that are not objects.
|
|
42
|
+
SEPARATOR = ("separator", 0)
|
|
43
|
+
DECORATORS = ("decorators", 0)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Rows(QStyledItemDelegate):
|
|
47
|
+
"""Every row as usual, except the separator, which is a line - and the
|
|
48
|
+
row a dragged label would be dropped on, which is framed."""
|
|
49
|
+
|
|
50
|
+
def paint(self, painter, option, index):
|
|
51
|
+
if index.data(Qt.UserRole) != SEPARATOR:
|
|
52
|
+
QStyledItemDelegate.paint(self, painter, option, index)
|
|
53
|
+
tree = self.parent()
|
|
54
|
+
target = getattr(tree, "_drop_item", None)
|
|
55
|
+
if (target is not None and index.column() == 0
|
|
56
|
+
and index == tree.indexFromItem(target, 0)):
|
|
57
|
+
painter.save()
|
|
58
|
+
painter.setPen(QPen(option.palette.color(QPalette.Highlight),
|
|
59
|
+
2))
|
|
60
|
+
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
|
|
61
|
+
painter.restore()
|
|
62
|
+
return
|
|
63
|
+
painter.save()
|
|
64
|
+
painter.setPen(QPen(option.palette.color(QPalette.Mid), 1))
|
|
65
|
+
y = option.rect.center().y()
|
|
66
|
+
painter.drawLine(option.rect.left() + 2, y, option.rect.right() - 6, y)
|
|
67
|
+
painter.restore()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _signals_of(sample, seg):
|
|
71
|
+
"""The curves one segment offers: its mass first where it recorded one
|
|
72
|
+
(an SDT run), then that mass's DTG, then its heat flow."""
|
|
73
|
+
numdata = (sample.data or {}).get("numdata", [])
|
|
74
|
+
step = numdata[seg] if seg < len(numdata) else {}
|
|
75
|
+
if model._records_mass(step):
|
|
76
|
+
return model.SIGNAL_ROWS
|
|
77
|
+
return (model.SIGNAL_HEAT,)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def first_line(text, limit=60):
|
|
81
|
+
"""A label's text as one row: its first line, and a mark if there is
|
|
82
|
+
more (a label can run over several lines)."""
|
|
83
|
+
lines = str(text).strip().splitlines() or [""]
|
|
84
|
+
head = lines[0].strip()
|
|
85
|
+
if len(lines) > 1:
|
|
86
|
+
head += " ..."
|
|
87
|
+
return head[:limit]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def decorators(doc):
|
|
91
|
+
"""What goes under Decorators, in order: the arrow, the legend, free
|
|
92
|
+
labels, pictures, structures."""
|
|
93
|
+
if doc is None:
|
|
94
|
+
return []
|
|
95
|
+
return ([doc.arrow, doc.legend]
|
|
96
|
+
+ [label for label in doc.labels if label.scan is None]
|
|
97
|
+
+ list(doc.images) + list(doc.structures))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Outliner(QTreeWidget):
|
|
101
|
+
"""The data (files, scans, what hangs on each scan), then a line, then
|
|
102
|
+
the decorators."""
|
|
103
|
+
|
|
104
|
+
#: The user ticked or unticked something: (object, visible).
|
|
105
|
+
visibility_changed = Signal(object, bool)
|
|
106
|
+
#: A segment that was not on the plot was ticked: (sample, seg, on).
|
|
107
|
+
segment_toggled = Signal(object, int, bool, str)
|
|
108
|
+
selection_picked = Signal()
|
|
109
|
+
activated_object = Signal(object)
|
|
110
|
+
menu_for = Signal(object, object) # object, global QPoint
|
|
111
|
+
#: A sweep across the boxes began / ended: what it changes between the
|
|
112
|
+
#: two is ONE undo step.
|
|
113
|
+
sweep_started = Signal()
|
|
114
|
+
sweep_finished = Signal()
|
|
115
|
+
#: Label rows were dropped: (labels, the scan to give them to, or None
|
|
116
|
+
#: to free them).
|
|
117
|
+
parent_requested = Signal(object, object)
|
|
118
|
+
#: A file's own box: (sample, on) - all its curves shown or hidden.
|
|
119
|
+
file_toggled = Signal(object, bool)
|
|
120
|
+
#: A file renamed in place (F2): (sample, new name; "" for the file's).
|
|
121
|
+
sample_renamed = Signal(object, str)
|
|
122
|
+
#: File rows dragged to a gap: (samples, the index of the gap).
|
|
123
|
+
samples_moved = Signal(object, int)
|
|
124
|
+
|
|
125
|
+
def __init__(self, parent=None):
|
|
126
|
+
QTreeWidget.__init__(self, parent)
|
|
127
|
+
self.setColumnCount(2)
|
|
128
|
+
self.setHeaderLabels(["Object", "State"])
|
|
129
|
+
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
|
130
|
+
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
131
|
+
self.setUniformRowHeights(True)
|
|
132
|
+
self.setIndentation(14)
|
|
133
|
+
# The names take the room there is and the state column is always
|
|
134
|
+
# whole: past a fixed 230-pixel first column it was cut off however
|
|
135
|
+
# wide the dock was.
|
|
136
|
+
header = self.header()
|
|
137
|
+
header.setStretchLastSection(False)
|
|
138
|
+
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
|
139
|
+
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
|
140
|
+
header.setMinimumSectionSize(40)
|
|
141
|
+
self.setItemDelegate(_Rows(self))
|
|
142
|
+
self.headerItem().setToolTip(0, "The figure's objects; tick to show.")
|
|
143
|
+
self.headerItem().setToolTip(1, "Mass, molar mass, exotherm, "
|
|
144
|
+
"offset, analyses shown.")
|
|
145
|
+
self.doc = None
|
|
146
|
+
self._filling = False
|
|
147
|
+
#: While a sweep is live, the state it paints onto every box it
|
|
148
|
+
#: passes (as in Blender), else None.
|
|
149
|
+
self._sweep = None
|
|
150
|
+
# Label rows drag onto scans. The tree never moves its own rows:
|
|
151
|
+
# a drop is a request to the window, and the rows are rebuilt.
|
|
152
|
+
# Copy, not Move: after a MOVE, Qt deletes the dragged rows itself.
|
|
153
|
+
self.setDragEnabled(True)
|
|
154
|
+
self.setAcceptDrops(True)
|
|
155
|
+
self.setDragDropMode(QAbstractItemView.DragDrop)
|
|
156
|
+
self.setDefaultDropAction(Qt.CopyAction)
|
|
157
|
+
self.setDropIndicatorShown(False)
|
|
158
|
+
#: The row a drag is over and would drop on, framed by `_Rows`.
|
|
159
|
+
self._drop_item = None
|
|
160
|
+
#: Where dragged FILES would land: the index of the gap, drawn as a
|
|
161
|
+
#: thin line between two files.
|
|
162
|
+
self._drop_gap = None
|
|
163
|
+
# F2 renames a file in place; a double-click still opens settings.
|
|
164
|
+
self.setEditTriggers(QAbstractItemView.EditKeyPressed)
|
|
165
|
+
self.itemChanged.connect(self._item_changed)
|
|
166
|
+
self.itemSelectionChanged.connect(self._selection_changed)
|
|
167
|
+
self.itemDoubleClicked.connect(self._double_clicked)
|
|
168
|
+
self.customContextMenuRequested.connect(self._menu)
|
|
169
|
+
|
|
170
|
+
# ----------------------------------------------------------------- fill
|
|
171
|
+
def set_document(self, doc):
|
|
172
|
+
self.doc = doc
|
|
173
|
+
self.refill()
|
|
174
|
+
|
|
175
|
+
def refill(self):
|
|
176
|
+
"""Rebuild the tree. Cheap: a figure has tens of rows, not thousands.
|
|
177
|
+
|
|
178
|
+
Never re-entrant: `clear()` deletes the rows, and deleting a row from
|
|
179
|
+
inside a signal that row is emitting is a use-after-free in Qt, not a
|
|
180
|
+
Python error - the program simply disappears.
|
|
181
|
+
"""
|
|
182
|
+
doc = self.doc
|
|
183
|
+
if self._filling:
|
|
184
|
+
return
|
|
185
|
+
self._filling = True
|
|
186
|
+
try:
|
|
187
|
+
# A file or the Decorators is open unless it was CLOSED by hand;
|
|
188
|
+
# a scan is closed unless it was opened. "Open unless anything
|
|
189
|
+
# was open before" folded every new file away once the
|
|
190
|
+
# Decorators existed on an empty figure.
|
|
191
|
+
expanded = {self._key(item) for item in self._items()
|
|
192
|
+
if item.isExpanded()}
|
|
193
|
+
collapsed = {self._key(item) for item in self._items()
|
|
194
|
+
if item.childCount() and not item.isExpanded()}
|
|
195
|
+
self.clear()
|
|
196
|
+
if doc is None:
|
|
197
|
+
return
|
|
198
|
+
for sample in doc.samples:
|
|
199
|
+
row = QTreeWidgetItem(self)
|
|
200
|
+
row.setText(0, sample.name)
|
|
201
|
+
if sample.sample_name != sample.name:
|
|
202
|
+
row.setToolTip(0, "Sample name in the file: {}".format(
|
|
203
|
+
sample.sample_name))
|
|
204
|
+
# Its own box shows and hides all its curves on the figure
|
|
205
|
+
# (ticked: all shown, half: some); a file with none has no
|
|
206
|
+
# box. Qt makes a row checkable by default, so without
|
|
207
|
+
# clearing the flag a press there makes an empty box that
|
|
208
|
+
# does nothing.
|
|
209
|
+
flags = row.flags() | Qt.ItemIsEditable
|
|
210
|
+
mine = [s for s in doc.scans if s.sample is sample]
|
|
211
|
+
if mine:
|
|
212
|
+
shown = sum(1 for s in mine if s.visible)
|
|
213
|
+
row.setCheckState(0, Qt.Checked if shown == len(mine)
|
|
214
|
+
else Qt.Unchecked if not shown
|
|
215
|
+
else Qt.PartiallyChecked)
|
|
216
|
+
else:
|
|
217
|
+
flags &= ~Qt.ItemIsUserCheckable
|
|
218
|
+
row.setFlags(flags)
|
|
219
|
+
row.setText(1, self._sample_state(sample))
|
|
220
|
+
row.setData(0, Qt.UserRole, ("sample", id(sample)))
|
|
221
|
+
row.setFirstColumnSpanned(False)
|
|
222
|
+
font = QFont(self.font())
|
|
223
|
+
font.setBold(True)
|
|
224
|
+
row.setFont(0, font)
|
|
225
|
+
if sample.exo_source == "assumed":
|
|
226
|
+
row.setForeground(1, QBrush(_DIM))
|
|
227
|
+
# EVERY segment of the file, not only the ones being drawn.
|
|
228
|
+
# A run holds seven, and comparing the second or third
|
|
229
|
+
# up-scan is routine, so the ones that are not shown yet have
|
|
230
|
+
# to be reachable - they are the unticked rows.
|
|
231
|
+
shown = {(scan.seg, scan.signal): scan for scan in doc.scans
|
|
232
|
+
if scan.sample is sample}
|
|
233
|
+
for seg in range(sample.segment_count()):
|
|
234
|
+
# An SDT segment is two curves, the mass first (its main
|
|
235
|
+
# curve), each its own row.
|
|
236
|
+
for signal in _signals_of(sample, seg):
|
|
237
|
+
scan = shown.get((seg, signal))
|
|
238
|
+
if scan is not None:
|
|
239
|
+
self._add_scan(row, scan)
|
|
240
|
+
else:
|
|
241
|
+
self._add_segment(row, sample, seg, signal)
|
|
242
|
+
row.setExpanded(("sample", id(sample)) not in collapsed)
|
|
243
|
+
for k in range(row.childCount()):
|
|
244
|
+
child = row.child(k)
|
|
245
|
+
child.setExpanded(self._key(child) in expanded)
|
|
246
|
+
if doc.samples:
|
|
247
|
+
line = QTreeWidgetItem(self)
|
|
248
|
+
line.setData(0, Qt.UserRole, SEPARATOR)
|
|
249
|
+
line.setFlags(Qt.NoItemFlags)
|
|
250
|
+
line.setFirstColumnSpanned(True)
|
|
251
|
+
group = QTreeWidgetItem(self)
|
|
252
|
+
group.setText(0, "Decorators")
|
|
253
|
+
group.setData(0, Qt.UserRole, DECORATORS)
|
|
254
|
+
font = QFont(self.font())
|
|
255
|
+
font.setBold(True)
|
|
256
|
+
group.setFont(0, font)
|
|
257
|
+
group.setToolTip(0, "Drawn on the figure, belonging to no scan")
|
|
258
|
+
for obj in decorators(doc):
|
|
259
|
+
self._add_decorator(group, obj)
|
|
260
|
+
group.setExpanded(DECORATORS not in collapsed)
|
|
261
|
+
self._drop_item = None
|
|
262
|
+
for item in self._items():
|
|
263
|
+
for column in (0, 1):
|
|
264
|
+
if item.text(column):
|
|
265
|
+
item.setToolTip(column, item.text(column))
|
|
266
|
+
# Only a label (onto a scan) and a file (into another
|
|
267
|
+
# place in the list) are dragged.
|
|
268
|
+
key = self._key(item)
|
|
269
|
+
if not key or key[0] not in ("label", "sample"):
|
|
270
|
+
item.setFlags(item.flags() & ~Qt.ItemIsDragEnabled)
|
|
271
|
+
finally:
|
|
272
|
+
self._filling = False
|
|
273
|
+
|
|
274
|
+
def _add_decorator(self, parent, obj):
|
|
275
|
+
"""One row under Decorators: a name, and what it is."""
|
|
276
|
+
if isinstance(obj, model.TextLabel):
|
|
277
|
+
return self._add_label_row(parent, obj)
|
|
278
|
+
item = QTreeWidgetItem(parent)
|
|
279
|
+
if isinstance(obj, model.HeatFlowArrow):
|
|
280
|
+
name, state = obj.name, "{} {}".format(obj.word, obj.direction)
|
|
281
|
+
elif isinstance(obj, model.Legend):
|
|
282
|
+
name = obj.name
|
|
283
|
+
state = "{} entries".format(len(obj.entries(self.doc)))
|
|
284
|
+
elif isinstance(obj, model.ImageArtist):
|
|
285
|
+
number = self.doc.images.index(obj) + 1
|
|
286
|
+
name = ("Picture {}".format(number) if len(self.doc.images) > 1
|
|
287
|
+
else "Picture")
|
|
288
|
+
state = "picture"
|
|
289
|
+
else: # a MoleculeArtist
|
|
290
|
+
name, state = (obj.smiles or obj.name), "structure"
|
|
291
|
+
item.setText(0, name)
|
|
292
|
+
item.setText(1, state)
|
|
293
|
+
if isinstance(obj, (model.ImageArtist, model.MoleculeArtist)):
|
|
294
|
+
item.setForeground(1, QBrush(_DIM))
|
|
295
|
+
item.setData(0, Qt.UserRole, (obj.kind, id(obj)))
|
|
296
|
+
item.setCheckState(0, Qt.Checked if obj.visible else Qt.Unchecked)
|
|
297
|
+
item.setSelected(obj.selected)
|
|
298
|
+
return item
|
|
299
|
+
|
|
300
|
+
def _add_scan(self, parent, scan):
|
|
301
|
+
item = QTreeWidgetItem(parent)
|
|
302
|
+
item.setText(0, scan.display_name())
|
|
303
|
+
item.setData(0, Qt.UserRole, ("scan", id(scan)))
|
|
304
|
+
item.setCheckState(0, Qt.Checked if scan.visible else Qt.Unchecked)
|
|
305
|
+
# The plot's colour for it, which on the light theme is the same
|
|
306
|
+
# hue darkened to read on white (`plot.for_light`).
|
|
307
|
+
colour = (plot_module.for_light(scan.colour)
|
|
308
|
+
if plot_module.THEME == plot_module.THEME_LIGHT
|
|
309
|
+
else QColor(scan.colour))
|
|
310
|
+
item.setForeground(0, QBrush(colour))
|
|
311
|
+
state = []
|
|
312
|
+
missing = (scan.missing_for(self.doc.unit_for(scan), self.doc.x_axis)
|
|
313
|
+
if self.doc else None)
|
|
314
|
+
if missing:
|
|
315
|
+
state.append("NO {}".format(missing.upper()))
|
|
316
|
+
item.setForeground(1, QBrush(_ALARM))
|
|
317
|
+
if scan.offset:
|
|
318
|
+
state.append("{:+.4g}".format(scan.offset))
|
|
319
|
+
analyses = scan.analysis_objects
|
|
320
|
+
if analyses:
|
|
321
|
+
state.append("{}/{} analyses".format(
|
|
322
|
+
len(scan.visible_analyses()), len(analyses)))
|
|
323
|
+
item.setText(1, " ".join(state))
|
|
324
|
+
item.setSelected(scan.selected)
|
|
325
|
+
# The analyses hang under their scan, each with its own box: one can
|
|
326
|
+
# be switched on by itself, and an uncertain attribution is visible
|
|
327
|
+
# without opening anything.
|
|
328
|
+
for analysis in analyses:
|
|
329
|
+
self._add_analysis(item, analysis)
|
|
330
|
+
for label in (self.doc.labels_for(scan) if self.doc else ()):
|
|
331
|
+
self._add_label_row(item, label)
|
|
332
|
+
if self.doc is not None and self.doc.offset_markers:
|
|
333
|
+
self._add_marker_row(item, scan.marker)
|
|
334
|
+
return item
|
|
335
|
+
|
|
336
|
+
def _add_marker_row(self, parent, marker):
|
|
337
|
+
"""The scan's offset marker, while the figure's markers are shown -
|
|
338
|
+
it is only an object then (`Document.objects`)."""
|
|
339
|
+
item = QTreeWidgetItem(parent)
|
|
340
|
+
item.setText(0, marker.name)
|
|
341
|
+
item.setData(0, Qt.UserRole, ("offset_marker", id(marker)))
|
|
342
|
+
item.setCheckState(0, Qt.Checked if marker.visible else Qt.Unchecked)
|
|
343
|
+
item.setText(1, "marker")
|
|
344
|
+
item.setForeground(1, QBrush(_DIM))
|
|
345
|
+
item.setSelected(marker.selected)
|
|
346
|
+
return item
|
|
347
|
+
|
|
348
|
+
def _add_label_row(self, parent, label):
|
|
349
|
+
item = QTreeWidgetItem(parent)
|
|
350
|
+
item.setText(0, first_line(label.text))
|
|
351
|
+
item.setData(0, Qt.UserRole, ("label", id(label)))
|
|
352
|
+
item.setCheckState(0, Qt.Checked if label.visible else Qt.Unchecked)
|
|
353
|
+
item.setText(1, "note" if label.leader else "label")
|
|
354
|
+
item.setForeground(1, QBrush(_DIM))
|
|
355
|
+
item.setSelected(label.selected)
|
|
356
|
+
return item
|
|
357
|
+
|
|
358
|
+
def _add_analysis(self, parent, analysis):
|
|
359
|
+
item = QTreeWidgetItem(parent)
|
|
360
|
+
item.setText(0, analysis.summary())
|
|
361
|
+
item.setData(0, Qt.UserRole, ("analysis", id(analysis)))
|
|
362
|
+
item.setCheckState(0, Qt.Checked if analysis.visible else Qt.Unchecked)
|
|
363
|
+
item.setSelected(analysis.selected)
|
|
364
|
+
if not analysis.certain:
|
|
365
|
+
item.setText(1, analysis.attribution)
|
|
366
|
+
item.setForeground(1, QBrush(_DIM))
|
|
367
|
+
elif not analysis.decoded:
|
|
368
|
+
item.setText(1, "cursors only")
|
|
369
|
+
item.setForeground(1, QBrush(_DIM))
|
|
370
|
+
return item
|
|
371
|
+
|
|
372
|
+
def _add_segment(self, parent, sample, seg, signal=model.SIGNAL_HEAT):
|
|
373
|
+
"""A curve of the file that is not being drawn: tick it to add it.
|
|
374
|
+
|
|
375
|
+
Named with the same `short_program` the drawn ones use, through a
|
|
376
|
+
throwaway `Scan`, so "#3 heat 10 K/min" reads the same whether it is
|
|
377
|
+
on the plot or waiting to be put there; an SDT segment's mass says
|
|
378
|
+
"mass" after it. The key is FOUR parts, `("segment", id, seg,
|
|
379
|
+
signal)` (the two-shape trap in CLAUDE.md: `_segment_of` reads it).
|
|
380
|
+
"""
|
|
381
|
+
item = QTreeWidgetItem(parent)
|
|
382
|
+
text = model.Scan(0, sample, seg, "#888888").short_program()
|
|
383
|
+
if signal == model.SIGNAL_MASS:
|
|
384
|
+
text += " mass"
|
|
385
|
+
elif signal == model.SIGNAL_DTG:
|
|
386
|
+
text += " DTG"
|
|
387
|
+
item.setText(0, text)
|
|
388
|
+
item.setData(0, Qt.UserRole, ("segment", id(sample), seg, signal))
|
|
389
|
+
item.setCheckState(0, Qt.Unchecked)
|
|
390
|
+
item.setForeground(0, QBrush(_DIM))
|
|
391
|
+
item.setText(1, "not shown")
|
|
392
|
+
item.setForeground(1, QBrush(_DIM))
|
|
393
|
+
return item
|
|
394
|
+
|
|
395
|
+
def _sample_state(self, sample):
|
|
396
|
+
bits = []
|
|
397
|
+
bits.append("{:g} mg".format(sample.mass_g * 1000.0)
|
|
398
|
+
if sample.mass_g else "no mass")
|
|
399
|
+
bits.append("M {:g}".format(sample.molar_mass) if sample.molar_mass
|
|
400
|
+
else "no M")
|
|
401
|
+
bits.append("exo {}{}".format(
|
|
402
|
+
sample.exo, "?" if sample.exo_source == "assumed" else ""))
|
|
403
|
+
return " ".join(bits)
|
|
404
|
+
|
|
405
|
+
# --------------------------------------------------- sweeping the boxes
|
|
406
|
+
# Press a box and drag down the list: every box the pointer passes takes
|
|
407
|
+
# the state the first one was given - as in Blender.
|
|
408
|
+
# A tap-and-drag on a touchpad and a double-click-drag both start one.
|
|
409
|
+
def _on_box(self, item, pos):
|
|
410
|
+
"""True when `pos` is on the tick box of `item`."""
|
|
411
|
+
if item is None or not (item.flags() & Qt.ItemIsUserCheckable):
|
|
412
|
+
return False
|
|
413
|
+
cell = self.visualItemRect(item)
|
|
414
|
+
left = self.visualRect(self.indexFromItem(item, 0)).left()
|
|
415
|
+
width = self.style().pixelMetric(QStyle.PM_IndicatorWidth) + 8
|
|
416
|
+
return cell.top() <= pos.y() <= cell.bottom() and \
|
|
417
|
+
left - 2 <= pos.x() <= left + width
|
|
418
|
+
|
|
419
|
+
def _start_sweep(self, item):
|
|
420
|
+
self._sweep = (Qt.Unchecked if item.checkState(0) == Qt.Checked
|
|
421
|
+
else Qt.Checked)
|
|
422
|
+
self.sweep_started.emit()
|
|
423
|
+
item.setCheckState(0, self._sweep)
|
|
424
|
+
|
|
425
|
+
def mousePressEvent(self, ev):
|
|
426
|
+
pos = ev.position().toPoint()
|
|
427
|
+
item = self.itemAt(pos)
|
|
428
|
+
if ev.button() == Qt.LeftButton and self._on_box(item, pos):
|
|
429
|
+
self._start_sweep(item)
|
|
430
|
+
ev.accept()
|
|
431
|
+
return
|
|
432
|
+
QTreeWidget.mousePressEvent(self, ev)
|
|
433
|
+
|
|
434
|
+
def mouseDoubleClickEvent(self, ev):
|
|
435
|
+
pos = ev.position().toPoint()
|
|
436
|
+
item = self.itemAt(pos)
|
|
437
|
+
if ev.button() == Qt.LeftButton and self._on_box(item, pos):
|
|
438
|
+
# The second press of a double-click on a box is a box press,
|
|
439
|
+
# not "open the settings".
|
|
440
|
+
if self._sweep is None:
|
|
441
|
+
self._start_sweep(item)
|
|
442
|
+
ev.accept()
|
|
443
|
+
return
|
|
444
|
+
QTreeWidget.mouseDoubleClickEvent(self, ev)
|
|
445
|
+
|
|
446
|
+
def mouseMoveEvent(self, ev):
|
|
447
|
+
if self._sweep is not None:
|
|
448
|
+
item = self.itemAt(ev.position().toPoint())
|
|
449
|
+
if (item is not None and item.flags() & Qt.ItemIsUserCheckable
|
|
450
|
+
and item.checkState(0) != self._sweep):
|
|
451
|
+
item.setCheckState(0, self._sweep)
|
|
452
|
+
ev.accept()
|
|
453
|
+
return
|
|
454
|
+
QTreeWidget.mouseMoveEvent(self, ev)
|
|
455
|
+
|
|
456
|
+
def mouseReleaseEvent(self, ev):
|
|
457
|
+
if self._sweep is not None:
|
|
458
|
+
self._sweep = None
|
|
459
|
+
# After the changes the sweep queued (they are deferred by a
|
|
460
|
+
# zero timer, `_item_changed`), so they land inside the step.
|
|
461
|
+
QTimer.singleShot(0, self.sweep_finished.emit)
|
|
462
|
+
ev.accept()
|
|
463
|
+
return
|
|
464
|
+
QTreeWidget.mouseReleaseEvent(self, ev)
|
|
465
|
+
|
|
466
|
+
# ------------------------------------------------ dragging labels over
|
|
467
|
+
def dragged_labels(self):
|
|
468
|
+
"""The labels among the selected rows: what a drag carries."""
|
|
469
|
+
out = []
|
|
470
|
+
for item in self.selectedItems():
|
|
471
|
+
obj = self._object(item)
|
|
472
|
+
if isinstance(obj, model.TextLabel):
|
|
473
|
+
out.append(obj)
|
|
474
|
+
return out
|
|
475
|
+
|
|
476
|
+
def drop_target(self, item):
|
|
477
|
+
"""`(True, scan)` for a row that gives a label to `scan` - the
|
|
478
|
+
scan's own row or anything under it - `(True, None)` for the
|
|
479
|
+
Decorators and what is under them (free the label), and
|
|
480
|
+
`(False, None)` for anywhere else."""
|
|
481
|
+
while item is not None:
|
|
482
|
+
key = self._key(item)
|
|
483
|
+
if key == DECORATORS:
|
|
484
|
+
return True, None
|
|
485
|
+
obj = self._object(item)
|
|
486
|
+
if isinstance(obj, model.Scan):
|
|
487
|
+
return True, obj
|
|
488
|
+
item = item.parent()
|
|
489
|
+
return False, None
|
|
490
|
+
|
|
491
|
+
def drop_labels_on(self, item):
|
|
492
|
+
"""Hand the dragged labels to what `item` stands for. True when it
|
|
493
|
+
was somewhere a label can go. The window is told after the drop has
|
|
494
|
+
finished, since it rebuilds these rows."""
|
|
495
|
+
ok, scan = self.drop_target(item)
|
|
496
|
+
labels = self.dragged_labels()
|
|
497
|
+
if not ok or not labels:
|
|
498
|
+
return False
|
|
499
|
+
QTimer.singleShot(0, lambda: self.parent_requested.emit(labels, scan))
|
|
500
|
+
return True
|
|
501
|
+
|
|
502
|
+
def _mark_drop(self, item):
|
|
503
|
+
if item is not self._drop_item:
|
|
504
|
+
self._drop_item = item
|
|
505
|
+
self.viewport().update()
|
|
506
|
+
|
|
507
|
+
# ------------------------------------------------ dragging files around
|
|
508
|
+
def dragged_samples(self):
|
|
509
|
+
"""The files among the selected rows, in the outliner's order."""
|
|
510
|
+
found = self.selected_samples()
|
|
511
|
+
if self.doc is not None:
|
|
512
|
+
found.sort(key=self.doc.samples.index)
|
|
513
|
+
return found
|
|
514
|
+
|
|
515
|
+
def _sample_rows(self):
|
|
516
|
+
return [self.topLevelItem(k) for k in range(self.topLevelItemCount())
|
|
517
|
+
if isinstance(self._object(self.topLevelItem(k)),
|
|
518
|
+
model.Sample)]
|
|
519
|
+
|
|
520
|
+
def gap_at(self, y):
|
|
521
|
+
"""The gap between files a drop at viewport `y` goes into: 0 above
|
|
522
|
+
the first file, n below the last - by which half of a file's block
|
|
523
|
+
(its row and everything open under it) the pointer is in."""
|
|
524
|
+
rows = self._sample_rows()
|
|
525
|
+
if not rows:
|
|
526
|
+
return None
|
|
527
|
+
tops = [self.visualItemRect(row).top() for row in rows]
|
|
528
|
+
ends = tops[1:] + [self._blocks_end(rows)]
|
|
529
|
+
for k, (top, end) in enumerate(zip(tops, ends)):
|
|
530
|
+
if y < top + (end - top) / 2.0:
|
|
531
|
+
return k
|
|
532
|
+
return len(rows)
|
|
533
|
+
|
|
534
|
+
def _blocks_end(self, rows):
|
|
535
|
+
"""The bottom of the last file's block: the top of the line below
|
|
536
|
+
the files, or the last visible row of that file."""
|
|
537
|
+
last = rows[-1]
|
|
538
|
+
bottom = self.visualItemRect(last).bottom()
|
|
539
|
+
stack = [last]
|
|
540
|
+
while stack:
|
|
541
|
+
item = stack.pop()
|
|
542
|
+
if item.isExpanded():
|
|
543
|
+
for k in range(item.childCount()):
|
|
544
|
+
child = item.child(k)
|
|
545
|
+
bottom = max(bottom, self.visualItemRect(child).bottom())
|
|
546
|
+
stack.append(child)
|
|
547
|
+
return bottom + 1
|
|
548
|
+
|
|
549
|
+
def gap_y(self, gap):
|
|
550
|
+
"""Where the line for `gap` is drawn, in viewport pixels."""
|
|
551
|
+
rows = self._sample_rows()
|
|
552
|
+
if not rows or gap is None:
|
|
553
|
+
return None
|
|
554
|
+
if gap >= len(rows):
|
|
555
|
+
return self._blocks_end(rows)
|
|
556
|
+
return self.visualItemRect(rows[gap]).top()
|
|
557
|
+
|
|
558
|
+
def _mark_gap(self, gap):
|
|
559
|
+
if gap != self._drop_gap:
|
|
560
|
+
self._drop_gap = gap
|
|
561
|
+
self.viewport().update()
|
|
562
|
+
|
|
563
|
+
def paintEvent(self, ev):
|
|
564
|
+
QTreeWidget.paintEvent(self, ev)
|
|
565
|
+
y = self.gap_y(self._drop_gap) if self._drop_gap is not None else None
|
|
566
|
+
if y is None:
|
|
567
|
+
return
|
|
568
|
+
painter = QPainter(self.viewport())
|
|
569
|
+
try:
|
|
570
|
+
painter.setPen(QPen(self.palette().color(QPalette.Highlight), 2))
|
|
571
|
+
painter.drawLine(QPointF(0.0, y), QPointF(
|
|
572
|
+
float(self.viewport().width()), y))
|
|
573
|
+
finally:
|
|
574
|
+
painter.end()
|
|
575
|
+
|
|
576
|
+
def drop_samples_at(self, gap):
|
|
577
|
+
"""Move the dragged files into `gap`. The window does it, after the
|
|
578
|
+
drop has finished (it rebuilds these rows)."""
|
|
579
|
+
samples = self.dragged_samples()
|
|
580
|
+
if not samples or gap is None:
|
|
581
|
+
return False
|
|
582
|
+
QTimer.singleShot(0, lambda: self.samples_moved.emit(samples, gap))
|
|
583
|
+
return True
|
|
584
|
+
|
|
585
|
+
def dragEnterEvent(self, ev):
|
|
586
|
+
if ev.source() is self and (self.dragged_labels()
|
|
587
|
+
or self.dragged_samples()):
|
|
588
|
+
ev.setDropAction(Qt.CopyAction)
|
|
589
|
+
ev.accept()
|
|
590
|
+
return
|
|
591
|
+
ev.ignore()
|
|
592
|
+
|
|
593
|
+
def dragMoveEvent(self, ev):
|
|
594
|
+
# The base class scrolls near the edges; whether the drop is
|
|
595
|
+
# allowed is decided here.
|
|
596
|
+
QTreeWidget.dragMoveEvent(self, ev)
|
|
597
|
+
if ev.source() is self and self.dragged_samples() and \
|
|
598
|
+
not self.dragged_labels():
|
|
599
|
+
self._mark_drop(None)
|
|
600
|
+
self._mark_gap(self.gap_at(ev.position().y()))
|
|
601
|
+
ev.setDropAction(Qt.CopyAction)
|
|
602
|
+
ev.accept()
|
|
603
|
+
return
|
|
604
|
+
self._mark_gap(None)
|
|
605
|
+
item = self.itemAt(ev.position().toPoint())
|
|
606
|
+
ok, _scan = self.drop_target(item)
|
|
607
|
+
if ev.source() is self and ok and self.dragged_labels():
|
|
608
|
+
self._mark_drop(item)
|
|
609
|
+
ev.setDropAction(Qt.CopyAction)
|
|
610
|
+
ev.accept()
|
|
611
|
+
else:
|
|
612
|
+
self._mark_drop(None)
|
|
613
|
+
ev.ignore()
|
|
614
|
+
|
|
615
|
+
def dragLeaveEvent(self, ev):
|
|
616
|
+
self._mark_drop(None)
|
|
617
|
+
self._mark_gap(None)
|
|
618
|
+
QTreeWidget.dragLeaveEvent(self, ev)
|
|
619
|
+
|
|
620
|
+
def dropEvent(self, ev):
|
|
621
|
+
"""Never the base class's: it would move the rows themselves."""
|
|
622
|
+
item = self.itemAt(ev.position().toPoint())
|
|
623
|
+
gap = self._drop_gap
|
|
624
|
+
self._mark_drop(None)
|
|
625
|
+
self._mark_gap(None)
|
|
626
|
+
if (ev.source() is self and gap is not None
|
|
627
|
+
and not self.dragged_labels()
|
|
628
|
+
and self.drop_samples_at(gap)):
|
|
629
|
+
ev.setDropAction(Qt.CopyAction)
|
|
630
|
+
ev.accept()
|
|
631
|
+
return
|
|
632
|
+
if ev.source() is self and self.drop_labels_on(item):
|
|
633
|
+
ev.setDropAction(Qt.CopyAction)
|
|
634
|
+
ev.accept()
|
|
635
|
+
else:
|
|
636
|
+
ev.ignore()
|
|
637
|
+
|
|
638
|
+
# ------------------------------------------------------------- plumbing
|
|
639
|
+
def _items(self):
|
|
640
|
+
out = []
|
|
641
|
+
|
|
642
|
+
def walk(item):
|
|
643
|
+
out.append(item)
|
|
644
|
+
for k in range(item.childCount()):
|
|
645
|
+
walk(item.child(k))
|
|
646
|
+
|
|
647
|
+
for i in range(self.topLevelItemCount()):
|
|
648
|
+
walk(self.topLevelItem(i))
|
|
649
|
+
return out
|
|
650
|
+
|
|
651
|
+
@staticmethod
|
|
652
|
+
def _key(item):
|
|
653
|
+
return item.data(0, Qt.UserRole)
|
|
654
|
+
|
|
655
|
+
def _segment_of(self, item):
|
|
656
|
+
"""`(sample, seg, signal)` for an unticked curve's row, else None."""
|
|
657
|
+
key = self._key(item)
|
|
658
|
+
if not key or key[0] != "segment" or self.doc is None:
|
|
659
|
+
return None
|
|
660
|
+
signal = key[3] if len(key) > 3 else model.SIGNAL_HEAT
|
|
661
|
+
for sample in self.doc.samples:
|
|
662
|
+
if id(sample) == key[1]:
|
|
663
|
+
return sample, key[2], signal
|
|
664
|
+
return None
|
|
665
|
+
|
|
666
|
+
def _object(self, item):
|
|
667
|
+
doc = self.doc
|
|
668
|
+
if doc is None or item is None:
|
|
669
|
+
return None
|
|
670
|
+
key = self._key(item)
|
|
671
|
+
if not key:
|
|
672
|
+
return None
|
|
673
|
+
kind, ident = key[0], key[1]
|
|
674
|
+
if kind in ("segment", "separator", "decorators"):
|
|
675
|
+
return None # not an object (a segment: not yet)
|
|
676
|
+
if kind == "legend":
|
|
677
|
+
return doc.legend
|
|
678
|
+
if kind == "arrow":
|
|
679
|
+
return doc.arrow
|
|
680
|
+
if kind in ("image", "molecule"):
|
|
681
|
+
for obj in (doc.images if kind == "image" else doc.structures):
|
|
682
|
+
if id(obj) == ident:
|
|
683
|
+
return obj
|
|
684
|
+
if kind == "offset_marker":
|
|
685
|
+
for scan in doc.scans:
|
|
686
|
+
if id(scan.marker) == ident:
|
|
687
|
+
return scan.marker
|
|
688
|
+
if kind == "scan":
|
|
689
|
+
for scan in doc.scans:
|
|
690
|
+
if id(scan) == ident:
|
|
691
|
+
return scan
|
|
692
|
+
if kind == "analysis":
|
|
693
|
+
for analysis in doc.analyses():
|
|
694
|
+
if id(analysis) == ident:
|
|
695
|
+
return analysis
|
|
696
|
+
if kind == "label":
|
|
697
|
+
for label in doc.labels:
|
|
698
|
+
if id(label) == ident:
|
|
699
|
+
return label
|
|
700
|
+
if kind == "sample":
|
|
701
|
+
for sample in doc.samples:
|
|
702
|
+
if id(sample) == ident:
|
|
703
|
+
return sample
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
def sync_selection(self):
|
|
707
|
+
"""Follow the document's selection, without echoing it back."""
|
|
708
|
+
self._filling = True
|
|
709
|
+
try:
|
|
710
|
+
for item in self._items():
|
|
711
|
+
obj = self._object(item)
|
|
712
|
+
item.setSelected(bool(getattr(obj, "selected", False)))
|
|
713
|
+
finally:
|
|
714
|
+
self._filling = False
|
|
715
|
+
|
|
716
|
+
def _item_changed(self, item, column):
|
|
717
|
+
"""A box was ticked. Tell the window - but not until Qt has finished.
|
|
718
|
+
|
|
719
|
+
**Deferred by a zero timer on purpose.** Acting at once means the
|
|
720
|
+
window rebuilds this tree while Qt is still inside the click that
|
|
721
|
+
ticked the box, so `clear()` deletes the very item the view is
|
|
722
|
+
holding, and the program vanishes without a traceback. Hiding the
|
|
723
|
+
only visible scan and then ticking another segment did exactly that.
|
|
724
|
+
|
|
725
|
+
A zero-delay `singleShot` runs as soon as the event loop is free,
|
|
726
|
+
which is after the click is finished and before anything is drawn.
|
|
727
|
+
"""
|
|
728
|
+
if self._filling or column != 0:
|
|
729
|
+
return
|
|
730
|
+
wanted = item.checkState(0) == Qt.Checked
|
|
731
|
+
sample = self._object(item)
|
|
732
|
+
if isinstance(sample, model.Sample):
|
|
733
|
+
self._sample_changed(item, sample)
|
|
734
|
+
return
|
|
735
|
+
segment = self._segment_of(item)
|
|
736
|
+
if segment is not None:
|
|
737
|
+
# An unticked segment becomes a scan when it is ticked. Ticking
|
|
738
|
+
# it back off goes through the scan row, not this one.
|
|
739
|
+
if wanted:
|
|
740
|
+
sample, seg, signal = segment
|
|
741
|
+
QTimer.singleShot(0, lambda: self.segment_toggled.emit(
|
|
742
|
+
sample, seg, True, signal))
|
|
743
|
+
return
|
|
744
|
+
obj = self._object(item)
|
|
745
|
+
if obj is None or not hasattr(obj, "visible"):
|
|
746
|
+
return
|
|
747
|
+
if bool(obj.visible) != wanted:
|
|
748
|
+
QTimer.singleShot(0, lambda: self.visibility_changed.emit(
|
|
749
|
+
obj, wanted))
|
|
750
|
+
|
|
751
|
+
def _sample_changed(self, item, sample):
|
|
752
|
+
"""A file's row changed: its name (F2) or its box."""
|
|
753
|
+
text = item.text(0).strip()
|
|
754
|
+
if text != sample.name:
|
|
755
|
+
name = "" if text in ("", sample.file_name) else text
|
|
756
|
+
QTimer.singleShot(0, lambda: self.sample_renamed.emit(sample,
|
|
757
|
+
name))
|
|
758
|
+
return
|
|
759
|
+
if not item.flags() & Qt.ItemIsUserCheckable:
|
|
760
|
+
return
|
|
761
|
+
mine = [s for s in self.doc.scans if s.sample is sample]
|
|
762
|
+
state = item.checkState(0)
|
|
763
|
+
if state == Qt.PartiallyChecked or not mine:
|
|
764
|
+
return
|
|
765
|
+
wanted = state == Qt.Checked
|
|
766
|
+
if any(bool(s.visible) != wanted for s in mine):
|
|
767
|
+
QTimer.singleShot(0, lambda: self.file_toggled.emit(sample,
|
|
768
|
+
wanted))
|
|
769
|
+
|
|
770
|
+
def rename(self, sample):
|
|
771
|
+
"""Start renaming a file in place (F2, or its menu)."""
|
|
772
|
+
for row in self._sample_rows():
|
|
773
|
+
if self._object(row) is sample:
|
|
774
|
+
self.setCurrentItem(row)
|
|
775
|
+
self.editItem(row, 0)
|
|
776
|
+
return row
|
|
777
|
+
return None
|
|
778
|
+
|
|
779
|
+
def _selection_changed(self):
|
|
780
|
+
if self._filling or self.doc is None:
|
|
781
|
+
return
|
|
782
|
+
chosen = []
|
|
783
|
+
for item in self.selectedItems():
|
|
784
|
+
obj = self._object(item)
|
|
785
|
+
if isinstance(obj, model.Obj):
|
|
786
|
+
chosen.append(obj)
|
|
787
|
+
elif isinstance(obj, model.Sample):
|
|
788
|
+
chosen.extend(obj.scans)
|
|
789
|
+
elif self._key(item) == DECORATORS:
|
|
790
|
+
chosen.extend(decorators(self.doc))
|
|
791
|
+
self.doc.select_only(chosen)
|
|
792
|
+
self.selection_picked.emit()
|
|
793
|
+
|
|
794
|
+
def selected_samples(self):
|
|
795
|
+
"""The files whose OWN rows are selected (not a curve under one)."""
|
|
796
|
+
found = []
|
|
797
|
+
for item in self.selectedItems():
|
|
798
|
+
obj = self._object(item)
|
|
799
|
+
if isinstance(obj, model.Sample) and obj not in found:
|
|
800
|
+
found.append(obj)
|
|
801
|
+
return found
|
|
802
|
+
|
|
803
|
+
def _double_clicked(self, item, _column):
|
|
804
|
+
obj = self._object(item)
|
|
805
|
+
if obj is not None:
|
|
806
|
+
# Deferred for the same reason as `_item_changed`: the settings
|
|
807
|
+
# dialog is modal, and opening one inside a click that is about
|
|
808
|
+
# to rebuild these rows is the same trap.
|
|
809
|
+
QTimer.singleShot(0, lambda: self.activated_object.emit(obj))
|
|
810
|
+
|
|
811
|
+
def _menu(self, pos):
|
|
812
|
+
item = self.itemAt(pos)
|
|
813
|
+
self.menu_for.emit(self._object(item), self.viewport().mapToGlobal(pos))
|
|
814
|
+
|
|
815
|
+
# Qt calls the handlers here by itself; an error in one is logged and
|
|
816
|
+
# survived rather than the end of the program (`core/log.py`).
|
|
817
|
+
from ..core import log as _log
|
|
818
|
+
_log.guard_classes(globals(), __name__)
|