forked from RomanHargrave/displaycal
-
Notifications
You must be signed in to change notification settings - Fork 88
Expand file tree
/
Copy pathmain_window.py
More file actions
10000 lines (9034 loc) · 431 KB
/
Copy pathmain_window.py
File metadata and controls
10000 lines (9034 loc) · 431 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""DisplayCAL main window — Qt port (Stage 3).
The wx main window is ``display_cal.MainFrame``: ~19,700 lines and 352 methods
driving the whole application. Porting it happens in vertical, independently
shippable slices (see ``DisplayCAL/ui/MAINFRAME_PORT_PLAN.md``, Stage 3+). This
module is the **shell** those slices grow into plus the four settings tabs:
* the top-level ``MainWindow(BaseWindow)`` window, its menubar and geometry
persistence (inherited from :class:`~DisplayCAL.ui.base_window.BaseWindow`),
* a tab bar of exclusive toggle buttons switching a :class:`QStackedWidget` of
settings panels (the wx custom ``TabButton`` / show-hide-panel mechanism),
* the **Display & Instrument** tab, wired to ``config`` and the binding-agnostic
:class:`~DisplayCAL.worker.Worker` display/port enumeration,
* the **Calibration**, **Profiling** and **3D LUT** tabs, whose config-backed
settings controls are wired to ``config`` through an ``_updating`` re-entrancy
guard (so repopulation never clobbers the stored selection),
* the calibrate / calibrate&profile / profile action-button bar, wired against
the Stage-2 :mod:`DisplayCAL.ui.measurement_flow` engine: each button stages a
:class:`MeasurementAction` and presents the measurement area (call-pending /
in-process measure frame / measure-frame subprocess on a :class:`QThread`),
emitting :attr:`MainWindow.measurement_requested` once the user commits.
The ``get_*`` settings getters deferred from Stage 0 land here as the Qt controls
that back them (whitepoint / TRC / luminance / quality) are built: they read
control state, mirroring the wx ``MainFrame`` getters, and are exercised through
the pure marshalling helpers at module scope.
Deferred to later slices (Pile 2 / Stage 5): the worker-driven Argyll execution
behind :attr:`MainWindow.measurement_requested` (the progress dialog and
interactive display-adjustment window), the pattern-generator setup dialogs
(Prisma / madTPG / Resolve), the visual-editor / ambient-measure buttons, and
the black-point-rate advanced control. Actually creating a 3D LUT
(:meth:`MainWindow.lut3d_create_btn_handler`, hidden behind the calibrate/
profile buttons whenever the 3D LUT tab is active with manual creation) now
runs ``worker.create_3dlut`` through the same
:class:`~DisplayCAL.ui.worker_runner.WorkerRunController` the other action
buttons use; see :mod:`DisplayCAL.lut3d_settings`'s module docstring for what
isn't reproduced there. On success (:meth:`MainWindow._on_lut3d_create_finished`)
it now also offers to install/copy the result
(:meth:`MainWindow._offer_install_3dlut` / :meth:`_install_3dlut`, a port of
wx's ``profile_finish`` re-entry from ``lut3d_create_consumer``), and
``3dlut.create`` now really does auto-chain LUT creation after a profiling
run instead of only hiding the manual button
(:meth:`MainWindow._chain_3dlut_after_profile`, called from
:meth:`_on_profile_build_finished`), with :meth:`MainWindow._check_lut3d_bpc`
(a port of ``MainFrame.lut3d_check_bpc``) warning if profile black-point
compensation is on at the same time. The madVR/Prisma **API** install branch
still isn't reproduced -- it needs the unported ``setup_patterngenerator``
connection dialogs -- so it shows a not-yet-available notice; only the
generic copy-to-path and ReShade-folder-detection destinations actually
install. The measurement-report settings live in the embedded **Verification**
tab (:class:`~DisplayCAL.ui.measurement_report.ReportPanel`, matching wx's
5th tab, ``display_cal.py:2450-2458``), whose "edit chart" button reuses the
already-ported :mod:`DisplayCAL.ui.tools.testchart_editor`; the shared
action-bar Measure button (:meth:`MainWindow.measurement_report_btn_handler`,
matching wx's ``buttonpanel``-level ``measurement_report_btn`` rather than a
per-tab one) runs the full chart/profile resolution, worker-driven
measurement and HTML report generation via
:mod:`DisplayCAL.measurement_report` (:meth:`MainWindow._on_report_measure_requested`
onward).
The pre-flight confirmation / overwrite dialogs (:meth:`MainWindow._check_overwrite`
/ :meth:`MainWindow._check_show_macos_bugs_warning` / :meth:`MainWindow
._current_cal_choice` / :meth:`MainWindow._fast_matrix_shaper_choice`, backed by
:mod:`DisplayCAL.preflight_checks`) now run ahead of every action button; not
reproduced there: the ``silent=True`` auto-retry call path (no auto-retry flow
exists in this port yet).
``show_advanced_options`` itself is wired (an Options-menu checkbox gating every
other row it controls that this port does have, including the whitepoint
colour-temperature-locus row (Calibration tab, via
:meth:`MainWindow._apply_whitepoint_mode`), the profile-type row's gamap
button, and the testchart-patch-sequence row), see
:meth:`MainWindow._update_advanced_options_visibility`. Profile-name token
expansion and the testchart chooser / patch-count / estimated-measurement-time
controls are wired via the toolkit-neutral :mod:`DisplayCAL.profile_name`
helpers, and the 3D LUT tab's TRC/HDR/content-colorspace/gamut-mapping/encoding
controls via :mod:`DisplayCAL.lut3d_settings`. The Tools menu carries the
colorimeter-correction import/upload actions
(:mod:`DisplayCAL.ui.colorimeter_correction_io`'s ``ImportController`` /
``UploadController``); the rest of wx's larger ``menu.tools`` isn't reproduced.
The Profiling tab's "Advanced..." (gamap) button opens the ported
:class:`~DisplayCAL.ui.gamap_window.GamapWindow` (:meth:`MainWindow
._gamap_btn_handler`), a singleton reused across opens. Its ``profile_settings_changed`` /
``b2a_quality_changed`` signals drive :meth:`MainWindow
._mark_profile_settings_changed` and :meth:`MainWindow._update_bpc` /
:meth:`MainWindow._update_lut3d_b2a_controls` respectively, replacing wx's
direct ``self.Parent`` attribute access. :meth:`MainWindow._update_bpc` (the
black-point-compensation checkbox's enable/checked state, a port of
``MainFrame.update_bpc``) is also called from :meth:`update_profile_controls`
and :meth:`_profile_type_ctrl_changed` — a real pre-existing gap before this
session, since Stage 3 never wired it at all.
The Help menu (:meth:`MainWindow._build_help_menu`) mirrors wx's
``menu.help`` in full: readme/license, website/support/bug-report, the
"check for updates" pair, and an About dialog
(:class:`DisplayCAL.ui.about_window.AboutWindow`).
:meth:`MainWindow.run_post_launch_checks` (called
by :mod:`DisplayCAL.ui.startup` once the window is shown) is the Qt port of
wx's ``StartupFrame.setup_frame_finish`` tail: a silent update check
(:mod:`DisplayCAL.ui.update_check_window`) chaining into the instrument-setup
/ donation-nag check (:mod:`DisplayCAL.instrument_setup`) when nothing needs
updating. The colorimeter-correction import prompt reuses the same
``ImportController`` the Tools menu does; the Spyder2 firmware-enable wizard
(:mod:`DisplayCAL.ui.spyder2_enable`'s ``Spyder2EnableController``) runs when
:mod:`DisplayCAL.instrument_setup` detects a Spyder2 that needs its firmware
enabled, and :meth:`MainWindow._on_spyder2_enable_finished` re-runs the whole
instrument-setup check afterward when
``InstrumentSetupNeeds.recheck_after_spyder2`` says other imports are still
pending, mirroring wx's ``enable_spyder2_consumer`` recursion into
``check_instrument_setup``.
The window is opt-in behind ``DISPLAYCAL_UI=qt`` / ``--qt`` (wired in
:mod:`DisplayCAL.main`), so it never displaces the still-shipping wx main window.
"""
from __future__ import annotations
import contextlib
import enum
import os
import platform
import re
import sys
from decimal import Decimal
from hashlib import md5
from typing import TYPE_CHECKING, Callable
from qtpy.QtCore import QEvent, QSize, Qt, QThread, QTimer, Signal
from qtpy.QtGui import QAction, QActionGroup, QColor, QIcon, QPainter, QPixmap
from qtpy.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QProgressDialog,
QPushButton,
QRadioButton,
QApplication,
QScrollArea,
QSizePolicy,
QSlider,
QSpinBox,
QStackedWidget,
QToolButton,
QVBoxLayout,
QWidget,
)
from DisplayCAL import (
calibration_file,
colorimeter_correction,
config,
create_profile,
gamap_settings,
instrument_setup,
lut3d_settings,
preflight_checks,
profile_finish,
)
from DisplayCAL.log import LOGBUFFER
from DisplayCAL import measurement_report as measurement_report_pipeline
from DisplayCAL import localization as lang
from DisplayCAL import profile_name as profile_name_mod
from DisplayCAL import report
from DisplayCAL.argyll import (
check_argyll_bin,
check_set_argyll_bin,
get_argyll_util,
make_argyll_compatible_path,
)
from DisplayCAL.argyll_instruments import get_canonical_instrument_name
from DisplayCAL.argyll_names import ALTNAMES as ARGYLL_ALTNAMES
from DisplayCAL.argyll_names import NAMES as ARGYLL_NAMES
from DisplayCAL.argyll_names import OPTIONAL as ARGYLL_OPTIONAL
from DisplayCAL.cgats import CGATS, CGATSError
from DisplayCAL.colorimeter_correction import ColorimeterCorrectionCatalog
from DisplayCAL.config import (
DEFAULTS,
EXE_EXT,
PROFILE_EXT,
get_data_path,
get_ui_toolkit,
get_verified_path,
getcfg,
restart_application,
setcfg,
setcfg_cond,
writecfg,
)
from DisplayCAL.icc_profile import (
CurveType,
ICCProfile,
ICCProfileInvalidError,
LUT16Type,
TextType,
VideoCardGammaType,
)
from DisplayCAL.meta import DEVELOPMENT_HOME_PAGE, DOMAIN
from DisplayCAL.meta import NAME as APPNAME
from DisplayCAL.meta import VERSION_STRING
from DisplayCAL.options import TEST
from DisplayCAL.ui.about_window import AboutWindow
from DisplayCAL.ui.application import Application
from DisplayCAL.ui.assets import (
get_header_icon_pixmap,
get_language_flag_pixmap,
get_theme_pixmap,
get_themed_pixmap,
)
from DisplayCAL.ui.theme import is_dark
from DisplayCAL.ui.base_window import BaseWindow
from DisplayCAL.ui.ccxx_plot_window import CCXXPlotWindow
from DisplayCAL.ui.colorimeter_correction_io import (
ImportController,
UploadController,
WebCheckController,
)
from DisplayCAL.ui.colorimeter_correction_window import CreateCorrectionWindow
from DisplayCAL.ui.display_adjustment_window import DisplayAdjustmentWindow
from DisplayCAL.ui.gamap_window import GamapWindow
from DisplayCAL.ui.header_banner import (
HEADER_BANNER_SIZE,
HeaderBanner,
header_banner_pixmap,
header_continuation_pixmap,
)
from DisplayCAL.ui.measure_frame import (
MeasureFrame,
default_measureframe_size,
resolve_screen_size_mm,
)
from DisplayCAL.ui import message_box
from DisplayCAL.ui.measurement_flow import (
MeasurementFlow,
PresentationMode,
build_measureframe_command,
interpret_measureframe_result,
observer_items,
run_measureframe_subprocess,
)
from DisplayCAL.ui.measurement_report import ReportPanel
from DisplayCAL.ui.measurement_sanity_dialog import MeasurementSanityDialog
from DisplayCAL.ui.patterngenerator_setup import Lut3DAPIInstallController
from DisplayCAL.ui.profile_finish_dialog import ProfileFinishDialog
from DisplayCAL.ui.profile_install_window import (
InstallProfileWindow,
show_install_summary,
)
from DisplayCAL.ui.progress_dialog import ProgressDialog
from DisplayCAL.ui.spyder2_enable import Spyder2EnableController
from DisplayCAL.ui.tooltip_window import TooltipWindow, info_text_html
from DisplayCAL.ui.tools.curve_viewer import CurveViewerWindow
from DisplayCAL.ui.tools.log_window import LogWindow
from DisplayCAL.ui.tools.lut3d import LUT3DWindow
from DisplayCAL.ui.tools.profile_info import ProfileInfoWindow
from DisplayCAL.ui.tools.synth_profile import SynthICCWindow
from DisplayCAL.ui.tools.testchart_editor import TestchartEditorWindow
from DisplayCAL.ui.tools.visual_whitepoint_editor import VisualWhitepointEditorWindow
from DisplayCAL.ui.update_check_window import UpdateCheckController
from DisplayCAL.ui.untethered_window import UntetheredWindow
from DisplayCAL.ui.worker_runner import (
AdjustmentController,
PasswordPromptAdapter,
UntetheredController,
WorkerRunController,
)
from DisplayCAL.util_decimal import stripzeros
from DisplayCAL.util_dict import dict_sort
from DisplayCAL.util_os import get_program_file, launch_file, waccess
from DisplayCAL.worker import (
Worker,
check_file_isfile,
get_options_from_cal,
get_options_from_profile,
parse_argument_string,
)
if TYPE_CHECKING:
from qtpy.QtGui import QPaintEvent, QShowEvent
#: The settings tabs, in order: ``(config-ish key, icon name, label key)``.
_TABS = (
("display_instrument", "display-instrument", "display-instrument"),
("calibration", "calibration", "calibration"),
("profiling", "profiling", "profiling"),
("lut3d", "3dlut", "3dlut"),
("verification", "dialog-ok", "verification"),
)
#: Calibration quality letters, ordered so ``index + 1`` is the wx slider value
#: (``MainFrame.quality_ab`` = ``{1: "v", 2: "l", 3: "m", 4: "h", 5: "u"}``).
CALIBRATION_QUALITY_LEVELS = ("v", "l", "m", "h", "u")
#: Profile quality letters, ordered so ``index + 1`` is the wx slider value
#: (``get_profile_quality`` = ``quality_ab[value + 1]``, i.e. ``l/m/h/u``).
PROFILE_QUALITY_LEVELS = ("l", "m", "h", "u")
#: quality letter -> ``calibration.speed.<x>`` suffix (speed is inverse quality).
_CALIBRATION_SPEED_LABELS = {
"v": "veryhigh",
"l": "high",
"m": "medium",
"h": "low",
"u": "verylow",
}
#: quality letter -> ``calibration.quality.<x>`` suffix (for the profile slider).
_PROFILE_QUALITY_LABELS = {"l": "low", "m": "medium", "h": "high", "u": "ultra"}
#: ``(config value, label key)`` pairs for the ``profile_type_ctrl`` combo, in
#: wx's ``update_profile_type_ctrl_items`` order. ``ProfileType`` (see
#: :mod:`DisplayCAL.profile_name`) is the source of truth; re-exported here
#: under its established name for this module's combo-building code and
#: ``tests/test_ui_main_window.py``.
PROFILE_TYPES = profile_name_mod.PROFILE_TYPES
ProfileType = profile_name_mod.ProfileType
#: Profile types whose gamut can be usefully remapped (enables ``gamap_btn``);
#: black point compensation also defaults off the first time one is selected.
_GAMUT_MAPPABLE_PROFILE_TYPES = (
ProfileType.LAB_LUT,
ProfileType.XYZ_LUT,
ProfileType.XYZ_LUT_MATRIX,
)
#: Curve+matrix profile types; black point compensation defaults on the first
#: time one is selected.
_CURVE_MATRIX_PROFILE_TYPES = (
ProfileType.SHAPER_MATRIX,
ProfileType.SINGLE_SHAPER_MATRIX,
)
#: Gamma-only profile types: Argyll only supports one profile-quality level
#: for these, so the quality slider is locked to "high".
_GAMMA_ONLY_PROFILE_TYPES = (
ProfileType.GAMMA_MATRIX,
ProfileType.SINGLE_GAMMA_MATRIX,
)
#: Calibration TRC selector entries, in display order (row index == combo row).
_TRC_ITEMS = (
"as_measured",
"Gamma 2.2",
"trc.lstar",
"trc.rec709",
"trc.rec1886",
"trc.smpte240m",
"trc.srgb",
"custom",
)
#: TRC rows whose value comes from the gamma text field.
_TRC_TEXT_ROWS = (1, 4, 7)
#: TRC rows that map straight to a fixed config value.
_TRC_FIXED = {2: "l", 3: "709", 5: "240", 6: "s"}
class MeasurementAction(enum.Enum):
"""Which measurement workflow an action button triggers.
Mirrors the wx button handlers (``calibrate_btn_handler`` etc.). The engine
stages one of these as the pending measurement; the worker-driven Argyll run
behind it lands in a later slice (see :meth:`MainWindow._drive_measurement`).
"""
#: Calibrate only (``MainFrame.just_calibrate``).
CALIBRATE = "calibrate"
#: Calibrate then characterize (``MainFrame.calibrate_and_profile``).
CALIBRATE_AND_PROFILE = "calibrate_and_profile"
#: Characterize only (``MainFrame.just_measure`` / ``just_profile``).
PROFILE = "profile"
class _MeasureframeSubprocessThread(QThread):
"""Run the measure-frame subprocess off the UI thread.
The Qt equivalent of the wx ``delayedresult`` producer around
``MainFrame.measureframe_subprocess``: it blocks in
:func:`~DisplayCAL.ui.measurement_flow.run_measureframe_subprocess` on a
worker thread and reports the ``(returncode, stderr)`` back to the window via
:attr:`finished_with_result`.
"""
#: Emitted with the subprocess ``(returncode, stderr)`` when it exits.
finished_with_result = Signal(int, str)
def __init__(
self, args: list[str], env: dict[str, str], parent: QWidget | None = None
) -> None:
super().__init__(parent)
self._args = args
self._env = env
#: The live subprocess, kept so the caller can terminate it.
self.process = None
def run(self) -> None: # noqa: D102 (QThread override)
returncode, stderr = run_measureframe_subprocess(
self._args, self._env, on_start=self._store_process
)
self.finished_with_result.emit(returncode, stderr)
def _store_process(self, process: object) -> None:
self.process = process
class _ProfileInstallThread(QThread):
"""Run :meth:`Worker.install_profile` off the GUI thread.
Backs :class:`~DisplayCAL.ui.profile_finish_dialog.ProfileFinishDialog`'s
accept path in :meth:`MainWindow._install_profile_direct`: the same
one-shot-behind-an-indeterminate-progress-dialog pattern as
:class:`~DisplayCAL.ui.profile_install_window._InstallThread`, just driven
by the main window's own worker instead of a standalone install window.
"""
#: Emitted with the ``(argyll, colord, oyranos, loader)`` result tuple, or
#: an ``Exception`` on failure.
done = Signal(object)
def __init__(
self, worker: Worker, profile_path: str, parent: QWidget | None = None
) -> None:
super().__init__(parent)
self._worker = worker
self._profile_path = profile_path
def run(self) -> None: # noqa: D102 (QThread override)
try:
result = self._worker.install_profile(
self._profile_path, capture_output=True, skip_scripts=False
)
except Exception as exception: # noqa: BLE001 (report on GUI thread)
result = exception
self.done.emit(result)
class _SessionArchiveThread(QThread):
"""Run :func:`~DisplayCAL.calibration_file.create_session_archive` off-thread.
The Qt equivalent of wx's ``worker.start(create_session_archive_consumer,
create_session_archive_producer, ...)`` pair (same one-shot-behind-a-
progress-dialog pattern as :class:`~DisplayCAL.ui.profile_install_window
._InstallThread`).
"""
#: Emitted with the archive result (``True``, or an ``Exception``).
done = Signal(object)
def __init__(
self,
request: calibration_file.SessionArchiveRequest,
exec_cmd: object,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self._request = request
self._exec_cmd = exec_cmd
def run(self) -> None: # noqa: D102 (QThread override)
result = calibration_file.create_session_archive(self._request, self._exec_cmd)
self.done.emit(result)
class _SessionArchiveImportThread(QThread):
"""Run :func:`~DisplayCAL.calibration_file.import_session_archive` off-thread.
The Qt equivalent of wx's ``worker.start(import_session_archive_consumer,
import_session_archive_producer, ...)`` pair.
"""
#: Emitted with the extraction result (a storage path, or an ``Exception``).
done = Signal(object)
def __init__(
self,
request: calibration_file.SessionArchiveImportRequest,
exec_cmd: object,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self._request = request
self._exec_cmd = exec_cmd
def run(self) -> None: # noqa: D102 (QThread override)
result = calibration_file.import_session_archive(self._request, self._exec_cmd)
self.done.emit(result)
#: Sentinel returned by :meth:`MainWindow._current_cal_choice` when the user
#: cancels, distinguishable from its other possible results (``None``,
#: ``False``, or a ``.cal`` path) -- the Qt stand-in for wx's ``wx.ID_CANCEL``.
CAL_CHOICE_CANCELLED = object()
class _CalChoiceDialog(QDialog):
"""Qt port of the checkbox dialog ``MainFrame.current_cal_choice`` builds.
Presents the "embed calibration" / "use linear instead" checkboxes
described by a :class:`~DisplayCAL.preflight_checks.CalChoiceInfo`, mirroring
wx's ``embed_cal_ctrl_handler`` (the reset checkbox is only enabled -- and
forced back on when disabled -- while embed is checked).
"""
def __init__(
self, info: preflight_checks.CalChoiceInfo, parent: QWidget | None = None
) -> None:
super().__init__(parent)
self.setWindowTitle(APPNAME)
layout = QVBoxLayout(self)
label = QLabel(
lang.getstr(
info.msg_key,
os.path.basename(info.cal_path) if info.cal_path else None,
)
)
label.setWordWrap(True)
layout.addWidget(label)
self._reset_cal_cb: QCheckBox | None = None
if info.show_reset_checkbox:
self._reset_cal_cb = QCheckBox(
lang.getstr("calibration.use_linear_instead")
)
layout.addWidget(self._reset_cal_cb)
self._embed_cal_cb = QCheckBox(lang.getstr("calibration.embed"))
self._embed_cal_cb.setChecked(info.show_reset_checkbox)
if self._reset_cal_cb is not None:
self._reset_cal_cb.setEnabled(self._embed_cal_cb.isChecked())
self._embed_cal_cb.toggled.connect(self._embed_cal_toggled)
layout.addWidget(self._embed_cal_cb)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Ok).setText(lang.getstr("continue"))
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _embed_cal_toggled(self, checked: bool) -> None:
self._reset_cal_cb.setEnabled(checked)
if not checked:
self._reset_cal_cb.setChecked(True)
def embed_cal(self) -> bool:
return self._embed_cal_cb.isChecked()
def reset_cal(self) -> bool:
return bool(self._reset_cal_cb and self._reset_cal_cb.isChecked())
class _DeleteConfirmationDialog(QDialog):
"""Qt port of the checkbox dialog ``MainFrame.display_delete_confirmation`` builds.
Lets the user individually toggle which of the calibration's related
files get deleted alongside it, mirroring wx's per-file
``wx.CheckBox`` list (``delete_calibration_related_handler``), all
pre-checked. A scroll area stands in for wx's ``ScrolledPanel`` so a long
file list doesn't grow the dialog unboundedly.
"""
def __init__(self, related_files: dict[str, bool], parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle(APPNAME)
layout = QVBoxLayout(self)
label = QLabel(lang.getstr("dialog.confirm_delete"))
label.setWordWrap(True)
layout.addWidget(label)
self._checks: dict[str, QCheckBox] = {}
if related_files:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setMaximumHeight(320)
container = QWidget()
container_layout = QVBoxLayout(container)
for related_file, checked in related_files.items():
cb = QCheckBox(related_file)
cb.setChecked(checked)
self._checks[related_file] = cb
container_layout.addWidget(cb)
container_layout.addStretch(1)
scroll.setWidget(container)
layout.addWidget(scroll)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Ok).setText(lang.getstr("delete"))
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def related_files(self) -> dict[str, bool]:
return {name: cb.isChecked() for name, cb in self._checks.items()}
class _DonationDialog(QDialog):
"""Qt port of ``display_cal.donation_message``.
Shown by :meth:`MainWindow._show_donation_message_if_needed` once no
instrument setup is pending, mirroring wx's post-``check_instrument_setup``
call to ``check_donation`` -> ``donation_message``. Accepting opens the
donation page and permanently clears ``show_donation_message``;
declining persists the "do not show again" checkbox instead.
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle(lang.getstr("welcome"))
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._icon_label = QLabel(self)
icon_pixmap = get_header_icon_pixmap()
if not icon_pixmap.isNull():
self._icon_label.setPixmap(icon_pixmap)
self._icon_label.setAlignment(Qt.AlignTop | Qt.AlignLeft)
layout.addWidget(self._icon_label, 0, Qt.AlignTop)
right_column = QVBoxLayout()
right_column.setContentsMargins(12, 12, 12, 12)
header = QLabel(lang.getstr("donation_header"), self)
font = header.font()
font.setPointSize(font.pointSize() + 4)
header.setFont(font)
right_column.addWidget(header)
message = QLabel(lang.getstr("donation_message"), self)
message.setWordWrap(True)
right_column.addWidget(message)
layout.addLayout(right_column)
buttons_row = QHBoxLayout()
self._do_not_show_again_cb = QCheckBox(
lang.getstr("dialog.do_not_show_again"), self
)
buttons_row.addWidget(self._do_not_show_again_cb)
buttons = QDialogButtonBox(self)
contribute_button = buttons.addButton(
lang.getstr("contribute"), QDialogButtonBox.AcceptRole
)
contribute_button.setDefault(True)
buttons.addButton(lang.getstr("not_now"), QDialogButtonBox.RejectRole)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
buttons_row.addWidget(buttons)
right_column.addLayout(buttons_row)
def accept(self) -> None: # noqa: D102 (Qt override)
launch_file(f"https://{DOMAIN}/#donate")
setcfg("show_donation_message", 0)
super().accept()
def reject(self) -> None: # noqa: D102 (Qt override)
setcfg(
"show_donation_message",
int(not self._do_not_show_again_cb.isChecked()),
)
super().reject()
class _UniformityLayoutDialog(QDialog):
"""Qt port of ``measure_uniformity_handler``'s patch-layout confirm dialog.
Lets the user pick the cols x rows patch grid before starting a
uniformity measurement, seeded from ``uniformity.cols``/``.rows``.
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle(APPNAME)
layout = QVBoxLayout(self)
label = QLabel(lang.getstr("patch.layout.select"), self)
layout.addWidget(label)
row = QHBoxLayout()
self._cols_combo = QComboBox(self)
self._cols_combo.addItems(
[str(value) for value in config.VALID_VALUES["uniformity.cols"]]
)
self._cols_combo.setCurrentText(str(getcfg("uniformity.cols")))
row.addWidget(self._cols_combo)
row.addWidget(QLabel("x", self))
self._rows_combo = QComboBox(self)
self._rows_combo.addItems(
[str(value) for value in config.VALID_VALUES["uniformity.rows"]]
)
self._rows_combo.setCurrentText(str(getcfg("uniformity.rows")))
row.addWidget(self._rows_combo)
layout.addLayout(row)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Ok).setText(lang.getstr("ok"))
buttons.button(QDialogButtonBox.Cancel).setText(lang.getstr("cancel"))
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def cols(self) -> int:
return int(self._cols_combo.currentText())
def rows(self) -> int:
return int(self._rows_combo.currentText())
class _LuminancePatchWindow(QWidget):
"""On-screen white/black patch for direct luminance measurement.
Qt port of the ad-hoc ``wx.Frame`` wx's ``luminance_measure_handler``
builds: a plain full-colour panel with a "Measure" button the user
positions over the instrument. Kept as its own lightweight floating
tool window (no menu bar, no geometry persistence) rather than reusing
:class:`~DisplayCAL.ui.measure_frame.MeasureFrame`, which is wired to
the dispcal/dispread subprocess flow instead of a one-shot ``spotread``
reading. Pattern-generator support (wx's ``setup_patterngenerator``)
isn't reproduced, matching the rest of this port's ambient/whitepoint
measure buttons.
"""
measure_requested = Signal()
def __init__(self, parent: QWidget, color: QColor) -> None:
super().__init__(parent, Qt.Tool)
self.setWindowTitle(lang.getstr("measureframe.title"))
self._color = color
size = self._default_size()
self.resize(size, size)
measure_btn = QPushButton(lang.getstr("measure"), self)
measure_btn.clicked.connect(self.measure_requested)
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
# Empty row above absorbs all growth, matching wx's FlexGridSizer(2,
# 3) with only the top row growable: the button sits on the bottom
# edge, horizontally centred, not in the middle of the patch.
layout.addStretch(1)
layout.addWidget(measure_btn, 0, Qt.AlignHCenter)
def _default_size(self) -> int:
"""100 mm square in pixels, matching wx's ad-hoc frame sizing.
Mirrors ``wx_measure_frame.get_default_size()`` via the same
physical-size resolution :class:`~DisplayCAL.ui.measure_frame
.MeasureFrame` uses, so the patch opens at a sensible on-screen size
instead of an arbitrary small default.
"""
screen = self.screen()
if screen is not None:
geo = screen.geometry()
geometry = (geo.x(), geo.y(), geo.width(), geo.height())
size_mm = resolve_screen_size_mm(screen, geometry)
if size_mm:
return default_measureframe_size((geo.width(), geo.height()), size_mm)
return int(DEFAULTS.get("size.measureframe", 300))
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 (Qt override)
painter = QPainter(self)
painter.fillRect(self.rect(), self._color)
super().paintEvent(event)
def _as_float(value: object) -> float | None:
"""Best-effort float coercion (``None`` when not numeric)."""
try:
return float(str(value).replace(",", "."))
except (TypeError, ValueError):
return None
def display_items(displays: list[str]) -> list[str]:
"""Localize raw worker display names for the display selector.
Mirrors the marshalling in ``MainFrame.update_displays``: the ``[PRIMARY]``
marker becomes the localized ``display.primary`` suffix and each name is run
through :func:`localization.getstr` (names are themselves lookup keys).
Args:
displays (list[str]): ``worker.displays`` entries.
Returns:
list[str]: Display labels for the combo box.
"""
items = []
for name in displays:
label = name.replace("[PRIMARY]", lang.getstr("display.primary"))
items.append(lang.getstr(label))
return items
def instrument_items(instruments: list[str]) -> list[str]:
"""Localize raw worker instrument names for the instrument selector.
Mirrors ``MainFrame.update_comports``: each instrument name maps to an
``instrument.<slug>`` localization key, falling back to the raw name.
Args:
instruments (list[str]): ``worker.instruments`` entries.
Returns:
list[str]: Instrument labels for the combo box.
"""
items = []
for instrument in instruments:
slug = instrument.lower().replace(" ", "_").replace(",", "")
items.append(lang.getstr(f"instrument.{slug}", default=instrument))
return items
def calibration_quality_to_slider(quality: str) -> int:
"""Return the calibration-quality slider value for a config letter.
Args:
quality (str): One of :data:`CALIBRATION_QUALITY_LEVELS`.
Returns:
int: The 1-based slider value (falls back to the config default).
"""
levels = CALIBRATION_QUALITY_LEVELS
if quality in levels:
return levels.index(quality) + 1
return levels.index(DEFAULTS["calibration.quality"]) + 1
def slider_to_calibration_quality(value: int) -> str:
"""Return the calibration-quality config letter for a slider value."""
index = min(max(value, 1), len(CALIBRATION_QUALITY_LEVELS)) - 1
return CALIBRATION_QUALITY_LEVELS[index]
def profile_quality_to_slider(quality: str) -> int:
"""Return the profile-quality slider value for a config letter."""
levels = PROFILE_QUALITY_LEVELS
if quality in levels:
return levels.index(quality) + 1
return levels.index(DEFAULTS["profile.quality"]) + 1
def slider_to_profile_quality(value: int) -> str:
"""Return the profile-quality config letter for a slider value."""
index = min(max(value, 1), len(PROFILE_QUALITY_LEVELS)) - 1
return PROFILE_QUALITY_LEVELS[index]
def trc_value_from_selection(index: int, text: str) -> str:
"""Return the ``trc`` config value for a TRC combo row + gamma text.
Mirrors ``MainFrame.get_trc``.
Args:
index (int): The selected TRC combo row.
text (str): The gamma text-field contents.
Returns:
str: The ``trc`` config value ("" = as-measured).
"""
if index in _TRC_TEXT_ROWS:
return str(stripzeros(text.replace(",", "."))) if text.strip() else ""
return _TRC_FIXED.get(index, "")
def trc_selection_from_config(
trc: object, trc_type: str, black_output_offset: object
) -> tuple[int, str, int]:
"""Return the TRC combo state for the stored config.
Mirrors the reverse mapping in ``MainFrame.update_calibration_file_ctrl``.
Args:
trc (object): The stored ``trc`` value (str or number).
trc_type (str): The stored ``trc.type`` ("g" or "G").
black_output_offset (object): The stored ``calibration.black_output_offset``.
Returns:
tuple[int, str, int]: ``(combo row, gamma text, type combo row)``.
"""
fixed_ba = {"l": 2, "709": 3, "240": 5, "s": 6}
if trc in fixed_ba:
return fixed_ba[trc], "", 0
trc_num = _as_float(trc)
boo = _as_float(black_output_offset)
if trc_num == 2.4 and trc_type == "G" and boo == 0:
return 4, str(trc), 1
type_row = 1 if trc_type == "G" else 0
if trc:
if trc_num == 2.2 and trc_type == "g" and boo == 1:
return 1, str(trc), type_row
return 7, str(trc), type_row
return 0, "", type_row
def lut3d_format_items(argyll_version: str = "0.0.0") -> list[tuple[str, str]]:
"""Return ``(config value, label)`` pairs for the 3D LUT file formats.
Mirrors ``LUT3DMixin.lut3d_setup_language``: madVR is only offered with
Argyll 1.6+.
"""
return [
(fmt, lang.getstr(f"3dlut.format.{fmt}"))
for fmt in config.VALID_VALUES["3dlut.format"]
if fmt != "madVR" or argyll_version >= "1.6"
]
def lut3d_rendering_intent_items(argyll_version: str = "0.0.0") -> list[tuple[str, str]]:
"""Return ``(config value, label)`` pairs for the 3D LUT rendering intents.
Mirrors ``LUT3DMixin.lut3d_setup_language``: "Perceptual, LUT proof"
(``"lp"``) needs Argyll 1.8.3+.
"""
return [
(ri, lang.getstr(f"gamap.intents.{ri}"))
for ri in config.VALID_VALUES["3dlut.rendering_intent"]
if ri != "lp" or argyll_version >= "1.8.3"
]
def lut3d_size_items() -> list[tuple[int, str]]:
"""Return ``(config value, label)`` pairs for the 3D LUT sizes."""
return [
(size, f"{size}x{size}x{size}")
for size in config.VALID_VALUES["3dlut.size"]
]
def lut3d_bitdepth_items() -> list[tuple[int, str]]:
"""Return ``(config value, label)`` pairs for the 3D LUT bit depths."""
return [(bit, str(bit)) for bit in config.VALID_VALUES["3dlut.bitdepth.input"]]
def lut3d_content_colorspace_items() -> list[str]:
"""Return the content-colorspace combo labels (named spaces + "custom")."""
return [*lut3d_settings.CONTENT_COLORSPACE_NAMES, lang.getstr("custom")]
def lut3d_encoding_items(codes: list[str]) -> list[tuple[str, str]]:
"""Return ``(config value, label)`` pairs for a list of encoding codes."""
return [(code, lang.getstr(f"3dlut.encoding.type_{code}")) for code in codes]
class _HeaderPanelBar(QWidget):
"""The "current file" bar beneath the header banner.
wx (``MainFrame``'s ``headerpanel``, ``display_cal.py``) doesn't let the
header artwork end at the banner: it overlays a second bitmap
(``self.header_btm``, the next ``80x120`` logical strip of
``theme/header.png``) as this bar's top-left background, continuing the
flare/circles graphic instead of cutting it off -- the source of a
reported "header clipped at the bottom" parity gap (the plain
stylesheet-only ``QWidget`` this replaces just showed flat blue there).
Painting it here, before the base ``paintEvent`` draws the stylesheet
background over the remainder and the ``QHBoxLayout`` children paint on
top, mirrors the same "paint explicitly, don't rely on sibling stacking"
approach already used by :class:`~DisplayCAL.ui.header_banner.HeaderBanner`.
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._continuation = header_continuation_pixmap()
self.setAttribute(Qt.WA_StyledBackground, True)
def paintEvent(self, event: QPaintEvent) -> None: # noqa: D102 (Qt override)
super().paintEvent(event)
if not self._continuation.isNull():
painter = QPainter(self)
painter.drawPixmap(0, 0, self._continuation)
painter.end()
class _TabStack(QStackedWidget):
"""A :class:`QStackedWidget` that sizes to the current page only.
Qt's default ``sizeHint()``/``minimumSizeHint()`` consider every child
page (so switching tabs never causes a layout jump), which means a wide
row on one settings tab silently forces a horizontal scrollbar on every
other, narrower tab -- surfaced when the Calibration tab's rows were
widened to use more of the tab's available width (issue: wx's own tabs
scroll independently, not in lockstep). Each tab manages its own
``QScrollArea`` behaviour via the shared wrapper in ``_build_ui``, so
there is no layout-jump downside to sizing only the visible page here.
Overriding ``sizeHint()``/``minimumSizeHint()`` on this widget isn't
enough by itself: the surrounding ``QScrollArea`` doesn't call these
Python overrides for its own auto-resize bookkeeping -- with
``widgetResizable=True`` it instead reads the *internal*
``QStackedLayout``'s own ``sizeHint()``/``minimumSize()`` (which still
unions every page) and, worse, only re-measures sporadically, so it tends
to permanently pin this widget to whichever tab was ever the biggest (or
to a construction-time snapshot of the first tab taken before
:meth:`MainWindow.update_controls`/``setup_language`` filled in its final
content) -- showing a scrollbar even on a tab that fits fine on its own.