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/colour.py ADDED
@@ -0,0 +1,629 @@
1
+ """The colour picker: a hue wheel, a value bar, typed numbers, a dropper.
2
+
3
+ Qt's own picker has a bar that runs to DARKER shades,
4
+ while the usual wish is a saturated colour made paler. Here the wheel is
5
+ hue by angle and saturation by radius with WHITE at the centre, so paler is
6
+ a move inwards, and darker is the bar beside it. Every number box takes a
7
+ sum ("255-20", `numbers.evaluate`); the hex field does not, because a sum
8
+ on a whole colour says nothing about which channel it means.
9
+
10
+ `get_colour` is the entry point, like `QColorDialog.getColor`: modal,
11
+ returning an invalid QColor when nothing was chosen. Like every pop-up here
12
+ it keeps its change however it is closed; Revert puts back the colour it
13
+ opened with.
14
+ """
15
+
16
+ import math
17
+
18
+ import numpy as np
19
+ from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
20
+ from PySide6.QtGui import (QColor, QCursor, QGuiApplication, QImage,
21
+ QLinearGradient, QPainter, QPen)
22
+ from PySide6.QtWidgets import (QCheckBox, QDialog, QDialogButtonBox,
23
+ QFormLayout, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
24
+ QSizePolicy, QToolButton, QVBoxLayout, QWidget)
25
+
26
+ from ..core import shades
27
+ from .numbox import NumberBox, WholeBox
28
+
29
+ #: The DSC_Plotter template's line colours, in its order (`colors` in its
30
+ #: driver): what the colour picker's basic colours start with.
31
+ PLOTTER_COLOURS = ("#0000ff", "#008000", "#ff0000", "#ffa500", "#ff00ff",
32
+ "#d2691e", "#00008b", "#005000", "#00ffff", "#008080",
33
+ "#800080", "#8a2be2", "#808080", "#800000", "#9acd32",
34
+ "#000000")
35
+ #: And after them: the panel's own screen colours, Tableau's ten,
36
+ #: Okabe and Ito's colour-blind-safe set, and greys.
37
+ MORE_COLOURS = ("#6ea8ff", "#ffb04e", "#7fd08a", "#e07b7b", "#c79bef",
38
+ "#4fd0c8", "#d8d16a", "#f08ac0",
39
+ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
40
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
41
+ "#e69f00", "#56b4e9", "#009e73", "#f0e442", "#0072b2",
42
+ "#d55e00", "#cc79a7",
43
+ "#ffffff", "#e0e0e0", "#c0c0c0", "#a0a0a0", "#606060",
44
+ "#404040", "#202020")
45
+
46
+
47
+ def _hsv_to_rgb(h, s, v):
48
+ """Vectorised HSV (all 0..1) to RGB (0..1)."""
49
+ h6 = (np.asarray(h, dtype=float) * 6.0) % 6.0
50
+ i = np.floor(h6).astype(int) % 6
51
+ f = h6 - np.floor(h6)
52
+ v = np.broadcast_to(np.asarray(v, dtype=float), h6.shape)
53
+ s = np.broadcast_to(np.asarray(s, dtype=float), h6.shape)
54
+ p = v * (1.0 - s)
55
+ q = v * (1.0 - s * f)
56
+ t = v * (1.0 - s * (1.0 - f))
57
+ red = np.choose(i, [v, q, p, p, t, v])
58
+ green = np.choose(i, [t, v, v, q, p, p])
59
+ blue = np.choose(i, [p, p, t, v, v, q])
60
+ return red, green, blue
61
+
62
+
63
+ def hsv_image(size, value):
64
+ """The hue/saturation disc at `value`, `size` pixels square: hue by
65
+ angle (red to the right, counter-clockwise), saturation by radius,
66
+ transparent outside, antialiased at the rim."""
67
+ n = max(int(size), 2)
68
+ centre = (n - 1) / 2.0
69
+ radius = n / 2.0 - 1.0
70
+ ys, xs = np.mgrid[0:n, 0:n].astype(float)
71
+ dx, dy = xs - centre, centre - ys
72
+ r = np.hypot(dx, dy)
73
+ h = (np.degrees(np.arctan2(dy, dx)) % 360.0) / 360.0
74
+ s = np.clip(r / radius, 0.0, 1.0)
75
+ red, green, blue = _hsv_to_rgb(h, s, float(value))
76
+ alpha = np.clip(radius + 0.5 - r, 0.0, 1.0)
77
+ # Premultiplied, so the rim blends rather than fringes.
78
+ argb = np.empty((n, n, 4), dtype=np.uint8)
79
+ argb[..., 0] = np.round(blue * alpha * 255)
80
+ argb[..., 1] = np.round(green * alpha * 255)
81
+ argb[..., 2] = np.round(red * alpha * 255)
82
+ argb[..., 3] = np.round(alpha * 255)
83
+ data = argb.tobytes()
84
+ image = QImage(data, n, n, 4 * n, QImage.Format_ARGB32_Premultiplied)
85
+ return image.copy()
86
+
87
+
88
+ class HueWheel(QWidget):
89
+ """Hue by angle, saturation by radius (white in the middle)."""
90
+
91
+ picked = Signal(float, float) # hue 0..1, saturation 0..1
92
+
93
+ def __init__(self, parent=None):
94
+ QWidget.__init__(self, parent)
95
+ self.setMinimumSize(200, 200)
96
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
97
+ self.h, self.s, self.v = 0.0, 0.0, 1.0
98
+ self._cache = None
99
+ self.setCursor(Qt.CrossCursor)
100
+
101
+ def sizeHint(self):
102
+ return QSize(230, 230)
103
+
104
+ def set_hsv(self, h, s, v):
105
+ self.h, self.s, self.v = h, s, v
106
+ self.update()
107
+
108
+ def _disc(self):
109
+ side = float(min(self.width(), self.height()))
110
+ return QRectF((self.width() - side) / 2.0,
111
+ (self.height() - side) / 2.0, side, side)
112
+
113
+ def point_of(self, h, s):
114
+ """Where (h, s) is on the wheel, in widget pixels."""
115
+ disc = self._disc()
116
+ radius = disc.width() / 2.0 - 1.0
117
+ angle = h * 2.0 * math.pi
118
+ return QPointF(disc.center().x() + math.cos(angle) * s * radius,
119
+ disc.center().y() - math.sin(angle) * s * radius)
120
+
121
+ def paintEvent(self, _ev):
122
+ disc = self._disc()
123
+ side = int(disc.width())
124
+ key = (side, round(self.v * 255))
125
+ if self._cache is None or self._cache[0] != key:
126
+ self._cache = (key, hsv_image(side, self.v))
127
+ p = QPainter(self)
128
+ p.drawImage(disc.topLeft(), self._cache[1])
129
+ p.setRenderHint(QPainter.Antialiasing, True)
130
+ at = self.point_of(self.h, self.s)
131
+ dark = QColor(0, 0, 0)
132
+ light = QColor(255, 255, 255)
133
+ p.setBrush(Qt.NoBrush)
134
+ p.setPen(QPen(dark if self.v > 0.5 else light, 1.5))
135
+ p.drawEllipse(at, 6.0, 6.0)
136
+ p.setPen(QPen(light if self.v > 0.5 else dark, 1.0))
137
+ p.drawEllipse(at, 7.5, 7.5)
138
+ p.end()
139
+
140
+ def pick(self, pos):
141
+ disc = self._disc()
142
+ radius = max(disc.width() / 2.0 - 1.0, 1.0)
143
+ dx = pos.x() - disc.center().x()
144
+ dy = disc.center().y() - pos.y()
145
+ s = min(math.hypot(dx, dy) / radius, 1.0)
146
+ h = (math.degrees(math.atan2(dy, dx)) % 360.0) / 360.0
147
+ if s == 0.0:
148
+ h = self.h # the centre keeps its hue
149
+ self.h, self.s = h, s
150
+ self.update()
151
+ self.picked.emit(h, s)
152
+
153
+ def mousePressEvent(self, ev):
154
+ if ev.button() == Qt.LeftButton:
155
+ self.pick(ev.position())
156
+
157
+ def mouseMoveEvent(self, ev):
158
+ if ev.buttons() & Qt.LeftButton:
159
+ self.pick(ev.position())
160
+
161
+
162
+ class ValueBar(QWidget):
163
+ """The value (brightness): full at the top, black at the bottom."""
164
+
165
+ picked = Signal(float)
166
+
167
+ def __init__(self, parent=None):
168
+ QWidget.__init__(self, parent)
169
+ self.setFixedWidth(26)
170
+ self.setMinimumHeight(200)
171
+ self.h, self.s, self.v = 0.0, 0.0, 1.0
172
+ self.setCursor(Qt.CrossCursor)
173
+
174
+ def set_hsv(self, h, s, v):
175
+ self.h, self.s, self.v = h, s, v
176
+ self.update()
177
+
178
+ def _bar(self):
179
+ return QRectF(4, 6, self.width() - 8, self.height() - 12)
180
+
181
+ def paintEvent(self, _ev):
182
+ bar = self._bar()
183
+ p = QPainter(self)
184
+ gradient = QLinearGradient(bar.topLeft(), bar.bottomLeft())
185
+ gradient.setColorAt(0.0, QColor.fromHsvF(self.h, self.s, 1.0))
186
+ gradient.setColorAt(1.0, QColor(0, 0, 0))
187
+ p.fillRect(bar, gradient)
188
+ p.setRenderHint(QPainter.Antialiasing, True)
189
+ y = bar.top() + (1.0 - self.v) * bar.height()
190
+ p.setPen(QPen(self.palette().text().color(), 1.5))
191
+ p.drawLine(QPointF(0, y), QPointF(bar.left() + 5, y))
192
+ p.drawLine(QPointF(bar.right() - 5, y), QPointF(self.width(), y))
193
+ p.end()
194
+
195
+ def pick(self, pos):
196
+ bar = self._bar()
197
+ v = 1.0 - (pos.y() - bar.top()) / max(bar.height(), 1.0)
198
+ self.v = min(max(v, 0.0), 1.0)
199
+ self.update()
200
+ self.picked.emit(self.v)
201
+
202
+ def mousePressEvent(self, ev):
203
+ if ev.button() == Qt.LeftButton:
204
+ self.pick(ev.position())
205
+
206
+ def mouseMoveEvent(self, ev):
207
+ if ev.buttons() & Qt.LeftButton:
208
+ self.pick(ev.position())
209
+
210
+
211
+ class Swatch(QToolButton):
212
+ """One colour to click."""
213
+
214
+ def __init__(self, colour, parent=None, size=18):
215
+ QToolButton.__init__(self, parent)
216
+ self.setFixedSize(size, size)
217
+ self.set_colour(colour)
218
+
219
+ def set_colour(self, colour):
220
+ self.colour = QColor(colour)
221
+ self.setToolTip(self.colour.name())
222
+ self.setStyleSheet("QToolButton {{ background: {}; border: 1px solid "
223
+ "#555; }}".format(self.colour.name()))
224
+
225
+
226
+ def screen_colour(pos=None):
227
+ """The colour on screen under `pos` (global; the pointer's if None)."""
228
+ pos = QCursor.pos() if pos is None else pos
229
+ screen = QGuiApplication.screenAt(pos)
230
+ if screen is None:
231
+ return None
232
+ origin = screen.geometry().topLeft()
233
+ pixmap = screen.grabWindow(0, pos.x() - origin.x(), pos.y() - origin.y(),
234
+ 1, 1)
235
+ image = pixmap.toImage()
236
+ if image.isNull():
237
+ return None
238
+ return QColor(image.pixel(0, 0))
239
+
240
+
241
+ class ColourDialog(QDialog):
242
+ """The picker. `colour()` is the chosen one; `live(name)`, when given,
243
+ hears every change as it happens, so the figure follows the wheel."""
244
+
245
+ changed = Signal(str)
246
+
247
+ def __init__(self, initial, parent=None, title="Pick a colour",
248
+ live=None):
249
+ QDialog.__init__(self, parent)
250
+ self.setWindowTitle(title)
251
+ initial = QColor(initial)
252
+ self._initial = initial if initial.isValid() else QColor(255, 255,
253
+ 255)
254
+ self._live = live
255
+ self._picking = None
256
+ self._busy = False
257
+ self._reverted = False
258
+ self.h = max(self._initial.hsvHueF(), 0.0)
259
+ self.s = self._initial.hsvSaturationF()
260
+ self.v = self._initial.valueF()
261
+
262
+ layout = QVBoxLayout(self)
263
+ top = QHBoxLayout()
264
+ layout.addLayout(top)
265
+ self.wheel = HueWheel(self)
266
+ self.wheel.setToolTip("Hue round the wheel, saturation outwards: "
267
+ "white in the middle, so paler is inwards.")
268
+ self.bar = ValueBar(self)
269
+ self.bar.setToolTip("Value: full at the top, black at the bottom.")
270
+ top.addWidget(self.wheel, 1)
271
+ top.addWidget(self.bar)
272
+
273
+ side = QVBoxLayout()
274
+ top.addLayout(side)
275
+ grid = QGridLayout()
276
+ side.addLayout(grid)
277
+ self.boxes = {}
278
+ rows = (("R", 255, ""), ("G", 255, ""), ("B", 255, ""),
279
+ ("H", 359, "\u00b0"), ("S", 100, " %"), ("V", 100, " %"))
280
+ for row, (name, high, suffix) in enumerate(rows):
281
+ box = WholeBox(self)
282
+ box.setRange(0, high)
283
+ box.setWrapping(name == "H")
284
+ box.setSuffix(suffix)
285
+ box.setToolTip("A number or a sum: 255-20.")
286
+ grid.addWidget(QLabel(name), row, 0)
287
+ grid.addWidget(box, row, 1)
288
+ box.valueChanged.connect(
289
+ (lambda n: lambda _v: self._typed(n))(name))
290
+ self.boxes[name] = box
291
+ grid.addWidget(QLabel("Hex"), len(rows), 0)
292
+ self.hex = QLineEdit(self)
293
+ self.hex.setMaxLength(7)
294
+ self.hex.setToolTip("#rrggbb, rrggbb or #rgb.")
295
+ self.hex.editingFinished.connect(self._typed_hex)
296
+ grid.addWidget(self.hex, len(rows), 1)
297
+
298
+ self.dropper = QPushButton("Pick from screen", self)
299
+ self.dropper.setToolTip("Then click anywhere on the screen to take "
300
+ "its colour. Esc or the right button "
301
+ "stops.")
302
+ self.dropper.setAutoDefault(False)
303
+ self.dropper.clicked.connect(lambda _c=False: self.start_picking())
304
+ side.addWidget(self.dropper)
305
+
306
+ compare = QHBoxLayout()
307
+ self.before = Swatch(self._initial, self, 34)
308
+ self.before.setToolTip("As it was. Click to go back to it.")
309
+ self.before.clicked.connect(
310
+ lambda _c=False: self.set_colour(self._initial))
311
+ self.after = Swatch(self._initial, self, 34)
312
+ self.after.setToolTip("As it is now.")
313
+ compare.addWidget(QLabel("Was"))
314
+ compare.addWidget(self.before)
315
+ compare.addWidget(QLabel("Now"))
316
+ compare.addWidget(self.after)
317
+ compare.addStretch(1)
318
+ side.addLayout(compare)
319
+ side.addStretch(1)
320
+
321
+ swatches = QGridLayout()
322
+ swatches.setSpacing(3)
323
+ for order, name in enumerate(PLOTTER_COLOURS + MORE_COLOURS):
324
+ swatch = Swatch(name, self)
325
+ swatch.clicked.connect(
326
+ (lambda c: lambda _c=False: self.set_colour(c))(name))
327
+ swatches.addWidget(swatch, order // 16, order % 16)
328
+ layout.addLayout(swatches)
329
+
330
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok
331
+ | QDialogButtonBox.Cancel)
332
+ revert = buttons.button(QDialogButtonBox.Cancel)
333
+ revert.setText("Revert")
334
+ revert.setToolTip("Put back the colour it had and close. Closing "
335
+ "otherwise keeps the new one.")
336
+ for button in buttons.buttons():
337
+ button.setAutoDefault(False)
338
+ buttons.accepted.connect(self.accept)
339
+ buttons.rejected.connect(self.revert)
340
+ layout.addWidget(buttons)
341
+
342
+ self.wheel.picked.connect(self._wheel_picked)
343
+ self.bar.picked.connect(self._bar_picked)
344
+ self._show(tell=False)
345
+
346
+ # -------------------------------------------------------------- state
347
+ def colour(self):
348
+ return QColor.fromHsvF(self.h, self.s, self.v)
349
+
350
+ def set_colour(self, colour):
351
+ colour = QColor(colour)
352
+ if not colour.isValid():
353
+ return
354
+ hue = colour.hsvHueF()
355
+ # A grey has no hue: keep the one there was, so the wheel does not
356
+ # jump to red on the way through white.
357
+ if hue >= 0:
358
+ self.h = hue
359
+ self.s, self.v = colour.hsvSaturationF(), colour.valueF()
360
+ self._show()
361
+
362
+ def _show(self, tell=True):
363
+ """Every widget from (h, s, v), and the colour to whoever listens."""
364
+ self._busy = True
365
+ try:
366
+ colour = self.colour()
367
+ self.wheel.set_hsv(self.h, self.s, self.v)
368
+ self.bar.set_hsv(self.h, self.s, self.v)
369
+ for name, value in (("R", colour.red()), ("G", colour.green()),
370
+ ("B", colour.blue()),
371
+ ("H", int(round(self.h * 360)) % 360),
372
+ ("S", int(round(self.s * 100))),
373
+ ("V", int(round(self.v * 100)))):
374
+ self.boxes[name].setValue(value)
375
+ self.hex.setText(colour.name())
376
+ self.hex.setStyleSheet("")
377
+ self.after.set_colour(colour)
378
+ finally:
379
+ self._busy = False
380
+ if tell:
381
+ name = self.colour().name()
382
+ self.changed.emit(name)
383
+ if self._live is not None:
384
+ self._live(name)
385
+
386
+ def _wheel_picked(self, h, s):
387
+ self.h, self.s = h, s
388
+ self._show()
389
+
390
+ def _bar_picked(self, v):
391
+ self.v = v
392
+ self._show()
393
+
394
+ def _typed(self, name):
395
+ if self._busy:
396
+ return
397
+ b = self.boxes
398
+ if name in "RGB":
399
+ self.set_colour(QColor(b["R"].value(), b["G"].value(),
400
+ b["B"].value()))
401
+ return
402
+ self.h = (b["H"].value() % 360) / 360.0
403
+ self.s = b["S"].value() / 100.0
404
+ self.v = b["V"].value() / 100.0
405
+ self._show()
406
+
407
+ def _typed_hex(self):
408
+ text = self.hex.text().strip()
409
+ if text and not text.startswith("#"):
410
+ text = "#" + text
411
+ colour = QColor(text)
412
+ if len(text) not in (4, 7) or not colour.isValid():
413
+ self.hex.setStyleSheet("border: 1px solid #d04040;")
414
+ return
415
+ if colour.name() != self.colour().name():
416
+ self.set_colour(colour)
417
+
418
+ # ------------------------------------------------------------ dropper
419
+ def start_picking(self):
420
+ """The dropper: the colour under the pointer follows it until a
421
+ click takes it; Esc or the right button puts back what there was."""
422
+ self._picking = self.colour()
423
+ self.setMouseTracking(True)
424
+ self.grabMouse(Qt.CrossCursor)
425
+ self.grabKeyboard()
426
+ self.dropper.setText("Click to take the colour")
427
+
428
+ def stop_picking(self, keep):
429
+ before, self._picking = self._picking, None
430
+ self.releaseMouse()
431
+ self.releaseKeyboard()
432
+ self.setMouseTracking(False)
433
+ self.dropper.setText("Pick from screen")
434
+ if not keep and before is not None:
435
+ self.set_colour(before)
436
+
437
+ def picking(self):
438
+ return self._picking is not None
439
+
440
+ def mouseMoveEvent(self, ev):
441
+ if self._picking is not None:
442
+ found = screen_colour(ev.globalPosition().toPoint())
443
+ if found is not None:
444
+ self.set_colour(found)
445
+ return
446
+ QDialog.mouseMoveEvent(self, ev)
447
+
448
+ def mousePressEvent(self, ev):
449
+ if self._picking is not None:
450
+ if ev.button() == Qt.LeftButton:
451
+ found = screen_colour(ev.globalPosition().toPoint())
452
+ if found is not None:
453
+ self.set_colour(found)
454
+ self.stop_picking(True)
455
+ else:
456
+ self.stop_picking(False)
457
+ return
458
+ QDialog.mousePressEvent(self, ev)
459
+
460
+ def keyPressEvent(self, ev):
461
+ if self._picking is not None:
462
+ if ev.key() == Qt.Key_Escape:
463
+ self.stop_picking(False)
464
+ return
465
+ if ev.key() in (Qt.Key_Return, Qt.Key_Enter):
466
+ # Enter takes a typed number (the box has it already); it does
467
+ # not close the picker (`dialogs.enter_stays`, the same rule).
468
+ return
469
+ QDialog.keyPressEvent(self, ev)
470
+
471
+ # ------------------------------------------------------------ closing
472
+ def reject(self):
473
+ """X or Esc keep the colour, as every pop-up does."""
474
+ if self._picking is not None:
475
+ self.stop_picking(False)
476
+ return
477
+ self.accept()
478
+
479
+ def revert(self):
480
+ self._reverted = True
481
+ self.set_colour(self._initial)
482
+ QDialog.reject(self)
483
+
484
+ def reverted(self):
485
+ return self._reverted
486
+
487
+
488
+ def get_colour(initial, parent=None, title="Pick a colour", live=None):
489
+ """Modal, like `QColorDialog.getColor`: the chosen colour, or an
490
+ invalid QColor if the picker was reverted. `live(name)` hears every
491
+ change while it is open (and the old colour again on Revert)."""
492
+ dialog = ColourDialog(initial, parent, title, live=live)
493
+ dialog.exec()
494
+ if dialog.reverted():
495
+ return QColor()
496
+ return dialog.colour()
497
+
498
+
499
+ class GradientDialog(QDialog):
500
+ """Shades of one colour on the selected curves, live (F3): a base
501
+ colour, how dark the darkest and how light the lightest, darkest at
502
+ the top of the stack unless reversed.
503
+
504
+ Built here and shown by the window (`MainWindow.colour_gradient`),
505
+ which makes the one undo step when it closes. Closing keeps the colours
506
+ however it is done; Revert puts the old ones back.
507
+ """
508
+
509
+ def __init__(self, scans, parent=None, on_change=None):
510
+ QDialog.__init__(self, parent)
511
+ self.setWindowTitle("Colour gradient")
512
+ self.scans = shades.stack_order(scans)
513
+ self.before = [(scan, scan.colour) for scan in self.scans]
514
+ self._on_change = on_change
515
+ self._reverted = False
516
+ self.base = QColor(self.scans[0].colour if self.scans else "#1f77b4")
517
+
518
+ layout = QVBoxLayout(self)
519
+ form = QFormLayout()
520
+ layout.addLayout(form)
521
+ self.base_button = Swatch(self.base, self, 28)
522
+ self.base_button.setToolTip("The colour the shades are made of.")
523
+ self.base_button.clicked.connect(lambda _c=False: self.pick_base())
524
+ form.addRow("Colour", self.base_button)
525
+ self.darkest = NumberBox(self)
526
+ self.lightest = NumberBox(self)
527
+ for box, value, word in ((self.darkest, -40.0, "darkest"),
528
+ (self.lightest, 50.0, "lightest")):
529
+ box.setRange(-100.0, 100.0)
530
+ box.setDecimals(0)
531
+ box.setSingleStep(5.0)
532
+ box.setSuffix(" %")
533
+ box.setValue(value)
534
+ box.setToolTip("The {} curve: the colour mixed towards black "
535
+ "(below 0) or white (above 0) by this much."
536
+ .format(word))
537
+ box.valueChanged.connect(lambda _v: self.apply())
538
+ form.addRow("Darkest", self.darkest)
539
+ form.addRow("Lightest", self.lightest)
540
+ self.reverse = QCheckBox("Lightest at the top")
541
+ self.reverse.setToolTip("Darkest at the top of the stack unless "
542
+ "ticked.")
543
+ self.reverse.toggled.connect(lambda _on: self.apply())
544
+ form.addRow("", self.reverse)
545
+ self.preview = QHBoxLayout()
546
+ self.preview.setSpacing(2)
547
+ self._swatches = []
548
+ for _scan in self.scans:
549
+ swatch = Swatch(self.base, self, 18)
550
+ swatch.setEnabled(False)
551
+ self._swatches.append(swatch)
552
+ self.preview.addWidget(swatch)
553
+ self.preview.addStretch(1)
554
+ form.addRow("Shades", self.preview)
555
+
556
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok
557
+ | QDialogButtonBox.Cancel)
558
+ revert = buttons.button(QDialogButtonBox.Cancel)
559
+ revert.setText("Revert")
560
+ revert.setToolTip("Put back the colours the curves had and close. "
561
+ "Closing otherwise keeps the new ones.")
562
+ for button in buttons.buttons():
563
+ button.setAutoDefault(False)
564
+ buttons.accepted.connect(self.accept)
565
+ buttons.rejected.connect(self.revert)
566
+ layout.addWidget(buttons)
567
+ self.apply()
568
+
569
+ def colours(self):
570
+ """The shades, top of the stack first."""
571
+ found = shades.shades(self.base.name(), len(self.scans),
572
+ self.darkest.value() / 100.0,
573
+ self.lightest.value() / 100.0)
574
+ return found[::-1] if self.reverse.isChecked() else found
575
+
576
+ def apply(self):
577
+ for scan, swatch, name in zip(self.scans, self._swatches,
578
+ self.colours()):
579
+ scan.colour = name
580
+ swatch.set_colour(name)
581
+ if self._on_change is not None:
582
+ self._on_change()
583
+
584
+ def pick_base(self):
585
+ was = QColor(self.base)
586
+
587
+ def live(name):
588
+ self.base = QColor(name)
589
+ self.base_button.set_colour(self.base)
590
+ self.apply()
591
+
592
+ chosen = get_colour(was, self, "Colour of the gradient", live=live)
593
+ live((chosen if chosen.isValid() else was).name())
594
+
595
+ def changes(self):
596
+ """`[(scan, "colour", new)]`, the old colours put back first so the
597
+ window's undo step records them."""
598
+ found = []
599
+ for scan, old in self.before:
600
+ new = scan.colour
601
+ if new != old:
602
+ scan.colour = old
603
+ found.append((scan, "colour", new))
604
+ return found
605
+
606
+ def keyPressEvent(self, ev):
607
+ if ev.key() in (Qt.Key_Return, Qt.Key_Enter):
608
+ return # takes a typed number, stays open
609
+ QDialog.keyPressEvent(self, ev)
610
+
611
+ def reject(self):
612
+ """X or Esc keep the colours, as every pop-up does."""
613
+ self.accept()
614
+
615
+ def revert(self):
616
+ self._reverted = True
617
+ for scan, old in self.before:
618
+ scan.colour = old
619
+ if self._on_change is not None:
620
+ self._on_change()
621
+ QDialog.reject(self)
622
+
623
+ def reverted(self):
624
+ return self._reverted
625
+
626
+ # Qt calls the handlers here by itself; an error in one is logged and
627
+ # survived rather than the end of the program (`core/log.py`).
628
+ from ..core import log as _log
629
+ _log.guard_classes(globals(), __name__)