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.
@@ -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__)