"""DisplayCAL - display calibration and characterization powered by ArgyllCMS.

Copyright (C) 2008, 2009 Florian Hoech

This program is free software;
you can redistribute it and/or modify it under the terms of the GNU General
Public License as published by the Free Software Foundation;
either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with
this program; if not, see <http://www.gnu.org/licenses/>
"""

# Standard modules
from __future__ import annotations

import contextlib
import datetime
import io
import json as json_module
import math
import os
import platform
import re
import shutil
import socket
import subprocess as sp
import sys
import threading
import requests
import traceback
import urllib.error
import urllib.parse
import urllib.request
import webbrowser  # Import the webbrowser module for platform-independent results
import zipfile
from decimal import Decimal
from hashlib import md5
from io import BytesIO, StringIO
from time import localtime, sleep, strftime, strptime, struct_time
from typing import TYPE_CHECKING, Callable
from zlib import crc32

from send2trash import send2trash

# wxPython
from wx import xrc
from wx.lib import delayedresult, platebtn
from wx.lib.art import flagart
from wx.lib.scrolledpanel import ScrolledPanel

# Local Imports
from DisplayCAL import (
    audio,
    ccmx,
    colord,
    colormath,
    config,
    floatspin,
    madvr,
    report,
    util_x,
    xh_bitmapctrls,
    xh_fancytext,
    xh_filebrowsebutton,
    xh_floatspin,
    xh_hstretchstatbmp,
)
from DisplayCAL import localization as lang
from DisplayCAL.argyll import (
    check_set_argyll_bin,
    get_argyll_instrument_config,
    get_argyll_latest_version,
    get_argyll_util,
    get_argyll_version,
    make_argyll_compatible_path,
    set_argyll_bin,
)
from DisplayCAL.argyll_cgats import (
    cal_to_fake_profile,
    can_update_cal,
    extract_cal_from_profile,
    ti3_to_ti1,
    verify_ti1_rgb_xyz,
)
from DisplayCAL.argyll_instruments import get_canonical_instrument_name, instruments
from DisplayCAL.argyll_names import VIEWCONDS
from DisplayCAL.cgats import (
    CGATS,
    CGATSError,
    CGATSInvalidError,
    CGATSInvalidOperationError,
    CGATSKeyError,
    CGATSTypeError,
    CGATSValueError,
)
from DisplayCAL.colormath import (
    XYZ2CCT,
    CIEDCCT2xyY,
    XYZ2Lab,
    XYZ2xyY,
    planckianCT2xyY,
    xyY2CCT,
)
from DisplayCAL.config import (
    AUTOSTART,
    AUTOSTART_HOME,
    DEFAULTS,
    ENC,
    EXE,
    EXE_EXT,
    FS_ENC,
    ISEXE,
    PROFILE_EXT,
    PYDIR,
    RES_FILES,
    SCRIPT_EXT,
    get_bitmap,
    get_ccxx_testchart,
    get_current_profile,
    get_data_path,
    get_display_profile,
    get_icon,
    get_total_patches,
    get_verified_path,
    getcfg,
    hascfg,
    initcfg,
    is_ccxx_testchart,
    is_profile,
    setcfg,
    setcfg_cond,
    writecfg,
)
from DisplayCAL.debughelpers import (
    getevtobjname,
    getevttype,
    handle_error,
)
from DisplayCAL.edid import PNP_ID_CACHE, get_manufacturer_name
from DisplayCAL.icc_profile import (
    GAMUT_VOLUME_ADOBERGB,
    GAMUT_VOLUME_SMPTE431_P3,
    GAMUT_VOLUME_SRGB,
    ChromaticAdaptionTag,
    CurveType,
    DictType,
    ICCProfile,
    ICCProfileInvalidError,
    ICCProfileTag,
    LUT16Type,
    TextDescriptionType,
    TextType,
    VideoCardGammaTableType,
    VideoCardGammaType,
    XYZType,
)
from DisplayCAL.log import LOGBUFFER
from DisplayCAL.meta import (
    AUTHOR,
    DEVELOPMENT_HOME_PAGE,
    DOMAIN,
    VERSION_STRING,
    VERSION_TUPLE,
    GITHUB_API_URL,
    get_latest_changelog_entry,
)
from DisplayCAL.meta import (
    NAME as APPNAME,
)
from DisplayCAL.options import (
    DEBUG,
    FORCE_SKIP_INITIAL_INSTRUMENT_DETECTION,
    TEST,
    TEST_UPDATE,
    VERBOSE,
)
from DisplayCAL.patterngenerators import WebWinHTTPPatternGeneratorServer
from DisplayCAL.util_decimal import float2dec, stripzeros
from DisplayCAL.util_dict import dict_sort, swap_dict_keys_values
from DisplayCAL.util_io import LineCache, TarFileProper
from DisplayCAL.util_list import index_fallback_ignorecase, intlist, natsort
from DisplayCAL.util_os import (
    dlopen,
    expanduseru,
    get_program_file,
    getenvu,
    is_superuser,
    launch_file,
    listdir_re,
    safe_glob,
    waccess,
    which,
)
from DisplayCAL.util_str import (
    ellipsis_,
    make_filename_safe,
    safe_str,
    strtr,
    universal_newlines,
    wrap,
)
from DisplayCAL.worker import (
    Error,
    FilteredStream,
    Info,
    UnloggedError,
    UnloggedInfo,
    UnloggedWarning,
    Warn,
    Worker,
    _applycal_bug_workaround,
    check_argyll_bin,
    check_create_dir,
    check_file_isfile,
    check_ti3,
    check_ti3_criteria1,
    check_ti3_criteria2,
    get_arg,
    get_argyll_util,
    get_cfg_option_from_args,
    get_current_profile_path,
    get_options_from_cal,
    get_options_from_profile,
    get_options_from_ti3,
    http_request,
    parse_argument_string,
    reload_black_videoluts,
    show_result_dialog,
)
from DisplayCAL.wx_addons import (
    CustomEvent,
    CustomGridCellEvent,
    IdFactory,
    PopupMenu,
    wx,
)
from DisplayCAL.wx_display_uniformity_frame import DisplayUniformityFrame
from DisplayCAL.wx_fixes import (
    BitmapWithThemedButton,
    PlateButton,
    TempXmlResource,
    ThemedGenButton,
    get_bitmap_disabled,
    set_bitmap_labels,
    set_maxsize,
    wx_Panel,
)
from DisplayCAL.wx_lut_3d_frame import LUT3DFrame, LUT3DMixin
from DisplayCAL.wx_measure_frame import MeasureFrame, get_default_size
from DisplayCAL.wx_report_frame import ReportFrame
from DisplayCAL.wx_synth_icc_frame import SynthICCFrame
from DisplayCAL.wx_testchart_editor import TestchartEditor
from DisplayCAL.wx_visual_whitepoint_editor import VisualWhitepointEditor
from DisplayCAL.wx_windows import (
    AboutDialog,
    AuiBetterTabArt,
    BaseApp,
    BaseFrame,
    BitmapBackgroundPanel,
    BitmapBackgroundPanelText,
    BorderGradientButton,
    ConfirmDialog,
    CustomCellBoolRenderer,
    CustomGrid,
    FileBrowseBitmapButtonWithChoiceHistory,
    FileDrop,
    HtmlWindow,
    HyperLinkCtrl,
    InfoDialog,
    LogWindow,
    TabButton,
    TooltipWindow,
    get_dialogs,
)

if sys.platform == "win32":
    import winreg

    from DisplayCAL import util_win
elif sys.platform == "darwin":
    from DisplayCAL import util_mac

try:
    from DisplayCAL.chromecast_pattern_generator import ChromeCastPatternGenerator
except ImportError:
    from types import NoneType

    ChromeCastPatternGenerator = NoneType

try:
    from DisplayCAL.wx_ccxx_plot import CCXXPlot
except ImportError:
    CCXXPlot = None

try:
    from DisplayCAL.wx_lut_viewer import LUTFrame
except ImportError:
    LUTFrame = None

try:
    from DisplayCAL.wx_profile_info import ProfileInfoFrame
except ImportError:
    ProfileInfoFrame = None

# wxPython
try:
    # Only wx.lib.aui.AuiNotebook looks reasonable across _all_ platforms.
    # Other tabbed book controls like wx.Notebook or wx.aui.AuiNotebook are
    # impossible to get to look right under GTK because there's no way to set
    # the correct background color for the pages.
    from wx.lib.agw import aui
except ImportError:
    # Fall back to wx.aui under ancient wxPython versions
    from wx import aui

if TYPE_CHECKING:
    from pathlib import Path


# Set no delay time to open the web page
webbrowser.PROCESS_CREATION_DELAY = 0

APP_IS_UP_TO_DATE = True
RELEASE_DATA: dict | None = None
COMPRESSED_FILE_EXTENSIONS = (".7z", ".tar.gz", ".tgz", ".zip")
ICCPROFILE_FILE_EXTENSIONS = (".icc", ".icm")
# Use conservative UI paths by default unless explicitly disabled.
SAFE_WX_UI = os.getenv("DISPLAYCAL_UNSAFE_WX_UI", "").strip() != "1"


def debug_print(*args, **kwargs) -> None:
    """Print debug messages if DEBUG is enabled."""
    if DEBUG:
        print(*args, **kwargs)


def verbose_print(*args, **kwargs) -> None:
    """Print verbose messages if VERBOSE is enabled."""
    if kwargs.pop("level", 1) <= VERBOSE:
        print(*args, **kwargs)


def set_platebutton_bitmaps(
    button: PlateButton, icon_name: str, size: int = 16
) -> None:
    """Set normal/hover/disabled bitmaps with crash-safe hover color lookup."""
    hover_suffix = ""
    if not SAFE_WX_UI:
        try:
            hover_suffix = button._color["htxt"].GetAsString(wx.C2S_HTML_SYNTAX)
        except Exception:
            hover_suffix = ""
    button.SetBitmapHover(get_icon(size, icon_name + hover_suffix))
    button.SetBitmapDisabled(get_bitmap_disabled(get_icon(size, icon_name)))


def show_ccxx_error_dialog(exception: Exception, path: str, parent: wx.Window) -> None:
    """Show a dialog with the error message from CGATS exception.

    Args:
        exception (Exception): The CGATS exception that was raised.
        path (str): The path to the CGATS file.
        parent (wx.Window): The parent window to show the dialog.
    """
    msg = str(exception)
    if msg.startswith("Malformed"):
        fn, ext = os.path.splitext(path)
        msg = lang.getstr("error.malformed_cgats", (ext[1:].upper(), path))
    show_result_dialog(msg, parent)


def get_download_url(newversion: str) -> str | None:
    """Return the GitHub release asset download URL for the current platform."""
    if RELEASE_DATA is None:
        return None
    if sys.platform == "win32":
        machine = platform.machine().lower()
        if machine in ("arm64", "aarch64"):
            filename = f"{APPNAME}-{newversion}-Windows-arm64.exe"
        else:
            filename = f"{APPNAME}-{newversion}-Windows-x64.exe"
    elif sys.platform == "darwin":
        machine = platform.machine().lower()
        if machine in ("arm64", "aarch64"):
            filename = f"{APPNAME}-{newversion}-macOS-arm64.dmg"
        else:
            filename = f"{APPNAME}-{newversion}-macOS-x86.dmg"
    else:
        filename = f"{APPNAME.lower()}-{newversion}.tar.gz"
    for asset in RELEASE_DATA["assets"]:
        if asset["name"] == filename:
            return asset["browser_download_url"]
    return None


def is_new_update() -> bool | tuple:
    """Check for new updates on GitHub.

    Returns:
        bool | tuple: The latest version tuple if a new update is available,
        False otherwise.
    """
    global RELEASE_DATA
    print("Checking for updates...")
    headers = {"User-Agent": "DisplayCAL-updater"}
    try:
        response = requests.get(
            f"{GITHUB_API_URL}/releases/latest", headers=headers, timeout=10
        )
        response.raise_for_status()
    except requests.RequestException as e:
        print(f"Error checking for updates: Network error - {str(e)}")
        return False

    try:
        data = response.json()
        RELEASE_DATA = data
        latest_version_tuple = tuple(int(n) for n in data["tag_name"].split("."))
    except (KeyError, ValueError, IndexError) as e:
        print(f"Error checking for updates: Parsing error - {str(e)}")
        return False

    current_version = VERSION_TUPLE[:3]
    if latest_version_tuple > current_version:
        print("New updates available!")
        return latest_version_tuple
    print("No new updates available.")
    return False


def app_update_check(
    parent: None | wx.Window = None,
    silent: bool = False,
    snapshot: bool = False,
    argyll: bool = False,
) -> None:
    """Check for application update. Show an error dialog if a failure occurs.

    Args:
        parent (None | wx.Window, optional): The parent window for the dialog.
        silent (bool, optional): If True, suppresses dialog display and
            only performs the update check.
        snapshot (bool, optional): If True, the application is a snapshot build.
        argyll (bool, optional): If True, check for ArgyllCMS update.
    """
    global APP_IS_UP_TO_DATE
    if argyll:
        if TEST_UPDATE:
            argyll_version = [0, 0, 0]
        elif parent and hasattr(parent, "worker"):
            argyll_version = parent.worker.argyll_version
        else:
            argyll_version = intlist(getcfg("argyll.version").split("."))
        curversion_tuple = tuple(argyll_version)
        chglog_file = "Argyll/ChangesSummary.html"
        # Fetch the latest ArgyllCMS version from argyllcms.com
        latest_argyll_str = get_argyll_latest_version()
        try:
            new_version_tuple = tuple(int(n) for n in latest_argyll_str.split("."))
        except (ValueError, AttributeError):
            print(lang.getstr("update_check.fail.version", "ArgyllCMS"))
            if not silent:
                wx.CallAfter(
                    InfoDialog,
                    parent,
                    msg=lang.getstr("update_check.fail.version", "ArgyllCMS"),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                    log=False,
                )
                return
            new_version_tuple = (0, 0, 0, 0)
        if not wx.GetApp():
            return
    elif snapshot:
        # Snapshot — fetch the version file directly from the project domain
        curversion_tuple = VERSION_TUPLE
        chglog_file = "SNAPSHOT_CHANGES.html"
        resp = http_request(
            parent,
            DOMAIN,
            "GET",
            "/SNAPSHOT_VERSION",
            failure_msg=lang.getstr("update_check.fail"),
            silent=silent,
        )
        if resp is False:
            if silent:
                # Check if we need to run instrument setup
                wx.CallAfter(
                    parent.check_instrument_setup, check_donation, (parent, snapshot)
                )
            return
        data = resp.read()
        if not wx.GetApp():
            return
        try:
            new_version_tuple = tuple(int(n) for n in data.decode().split("."))
        except ValueError:
            print(lang.getstr("update_check.fail.version", DOMAIN))
            if not silent:
                wx.CallAfter(
                    InfoDialog,
                    parent,
                    msg=lang.getstr("update_check.fail.version", DOMAIN),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                    log=False,
                )
                return
            new_version_tuple = (0, 0, 0, 0)
    else:
        # Stable
        print(lang.getstr("update_check"))
        curversion_tuple = VERSION_TUPLE
        chglog_file = "CHANGES.html"
        resp = is_new_update()
        if resp is False:
            if silent:
                # Check if we need to run instrument setup
                wx.CallAfter(
                    parent.check_instrument_setup, check_donation, (parent, snapshot)
                )
                return
            # Non-silent with no update available: fall through to the
            # "up to date" branches below using the current version.
            resp = curversion_tuple
        if not wx.GetApp():
            return
        new_version_tuple = resp

    if not argyll:
        APP_IS_UP_TO_DATE = new_version_tuple <= curversion_tuple
    if new_version_tuple > curversion_tuple:
        # Get changelog
        resp = http_request(parent, DOMAIN, "GET", "/" + chglog_file, silent=True)
        chglog = None
        if resp:
            readme = str(resp.read())
            if argyll:
                chglog = readme
            else:
                chglog = get_latest_changelog_entry(readme)
                if chglog:
                    chglog = f"""<!DOCTYPE html>
<html>
<head>
    <title></title>
</head>
<body>
{chglog}
</body>"""
            if chglog:
                chglog = re.sub(
                    re.compile(r"<h\d>(.+?)</h\d>", flags=re.I | re.S),
                    r"<p><strong>\1</strong></p>",
                    chglog,
                )
                chglog = re.sub(
                    re.compile(r'href="(#[^"]+)"', flags=re.I),
                    rf'href="https://{DOMAIN}/\1"',
                    chglog,
                )

        if not wx.GetApp():
            return

        wx.CallAfter(
            app_update_confirm,
            parent,
            new_version_tuple,
            chglog,
            snapshot,
            argyll,
            silent,
        )
    elif not argyll and not snapshot:
        app_update_check(parent, silent, True)
    elif not argyll:
        print(lang.getstr("update_check.uptodate", APPNAME))
        if check_argyll_bin():
            app_update_check(parent, silent, argyll=True)
        elif silent:
            wx.CallAfter(
                parent.set_argyll_bin_handler,
                True,
                silent,
                parent.check_instrument_setup,
                (check_donation, (parent, snapshot)),
            )
        else:
            wx.CallAfter(parent.set_argyll_bin_handler, True)
    elif not silent:
        print(lang.getstr("update_check.uptodate", "ArgyllCMS"))
        wx.CallAfter(
            app_up_to_date,
            parent,
            "ArgyllCMS" if not globals().get("APP_IS_UP_TO_DATE") else APPNAME,
        )
    else:
        print(lang.getstr("update_check.uptodate", "ArgyllCMS"))
        # Check if we need to run instrument setup
        wx.CallAfter(parent.check_instrument_setup, check_donation, (parent, snapshot))


def check_donation(parent: wx.Window, snapshot: bool) -> None:
    """Check if we need to show donation message.

    Show donation popup if user did not choose "don't show again".
    Reset donation popup after a major update.

    Args:
        parent (wx.Window): Parent window to show the dialog.
        snapshot (bool): If True, the application is a snapshot build.
    """
    if (
        not snapshot
        and VERSION_TUPLE[0] > intlist(getcfg("last_launch", "0.0.0").split("."))[0]
    ):
        setcfg("show_donation_message", 1)
        # store the current version as the last_launch version
        setcfg("last_launch", VERSION_STRING)
    if getcfg("show_donation_message"):
        wx.CallAfter(donation_message, parent)


def app_up_to_date(parent: None | wx.Window = None, appname: str = APPNAME) -> None:
    """Show a dialog confirming application is up-to-date.

    Args:
        parent (None | wx.Window, optional): The parent window for the dialog.
        appname (str, optional): The name of the application to display in the
            dialog.
    """
    dlg = InfoDialog(
        parent,
        msg=lang.getstr("update_check.uptodate", appname),
        ok=lang.getstr("ok"),
        bitmap=get_icon(32, "dialog-information"),
        show=False,
        log=False,
    )
    update_check = wx.CheckBox(dlg, -1, lang.getstr("update_check.onstartup"))
    update_check.SetValue(getcfg("update_check"))
    dlg.Bind(
        wx.EVT_CHECKBOX,
        lambda event: setcfg("update_check", int(event.IsChecked())),
        id=update_check.GetId(),
    )
    dlg.sizer3.Add(update_check, flag=wx.TOP | wx.ALIGN_LEFT, border=12)
    dlg.sizer0.SetSizeHints(dlg)
    dlg.sizer0.Layout()
    dlg.ShowModalThenDestroy()
    if parent and getattr(parent, "menuitem_app_auto_update_check", None):
        parent.menuitem_app_auto_update_check.Check(bool(getcfg("update_check")))


def app_update_confirm(
    parent: None | wx.Window = None,
    new_version_tuple: tuple = (0, 0, 0, 0),
    chglog: None | str = None,
    snapshot: bool = False,
    argyll: bool = False,
    silent: bool = False,
) -> None:
    """Show a dialog confirming application update, with cancel option.

    Args:
        parent (None | wx.Window, optional): The parent window for the dialog.
        new_version_tuple (tuple, optional): The new version tuple to display.
        chglog (str | None, optional): The changelog HTML content.
        snapshot (bool, optional): If True, the application is a snapshot build.
        argyll (bool, optional): If True, the update is for ArgyllCMS.
        silent (bool, optional): If True, suppresses dialog display and
            only performs the update check.
    """
    zeroinstall = False
    download = argyll and not check_argyll_bin()
    if zeroinstall or sys.platform in ("darwin", "win32") or argyll:
        ok = lang.getstr("download" if download else "update_now")
        alt = lang.getstr("go_to_website")
    else:
        ok = lang.getstr("go_to_website")
        alt = None
    newversion = ".".join(str(n) for n in new_version_tuple)
    if argyll:
        newversion_desc = "ArgyllCMS"
        try:
            newversion = get_argyll_latest_version()
        except Exception as exception:
            # Keep update flow alive even if version lookup/parsing fails.
            print(f"Could not determine latest ArgyllCMS version: {exception}")
            newversion = config.DEFAULTS.get("argyll.version", "unknown")
    else:
        newversion_desc = APPNAME
    newversion_desc += f" {newversion}"
    if snapshot:
        newversion_desc += " Beta"
    if download:
        msg = "{} {}".format(lang.getstr("download"), newversion_desc)
    else:
        msg = lang.getstr("update_check.new_version", newversion_desc)
    dlg = ConfirmDialog(
        parent,
        msg=msg,
        ok=ok,
        alt=alt,
        cancel=lang.getstr("cancel"),
        bitmap=get_icon(32, "dialog-information"),
        log=True,
    )
    scale = getcfg("app.dpi") / config.get_default_dpi()
    scale = max(scale, 1)
    if (
        argyll
        and sys.platform not in ("darwin", "win32")
        and not dlopen("libXss.so")
        and not dlopen("libXss.so.1")
    ):
        sizer = wx.BoxSizer(wx.HORIZONTAL)
        dlg.sizer3.Insert(0, sizer, flag=wx.BOTTOM | wx.ALIGN_LEFT, border=12)
        sizer.Add(wx.StaticBitmap(dlg, -1, get_icon(16, "dialog-warning")))
        warning_text = lang.getstr(
            "library.not_found.warning", (lang.getstr("libXss.so"), "libXss.so")
        )
        warning = wx.StaticText(dlg, -1, warning_text)
        warning.ForegroundColour = "#F07F00"
        sizer.Add(warning, flag=wx.LEFT, border=8)
        warning.Wrap((500 - 16 - 8) * scale)
    if chglog and not SAFE_WX_UI:
        htmlwnd = HtmlWindow(
            dlg, -1, size=(500 * scale, 300 * scale), style=wx.BORDER_THEME
        )
        htmlwnd.SetPage(chglog)
        dlg.sizer3.Add(htmlwnd, 1, flag=wx.TOP | wx.ALIGN_LEFT | wx.EXPAND, border=12)
    update_check = wx.CheckBox(dlg, -1, lang.getstr("update_check.onstartup"))
    update_check.SetValue(getcfg("update_check"))
    dlg.Bind(
        wx.EVT_CHECKBOX,
        lambda event: setcfg("update_check", int(event.IsChecked())),
        id=update_check.GetId(),
    )
    dlg.sizer3.Add(update_check, flag=wx.TOP | wx.ALIGN_LEFT, border=12)
    dlg.sizer0.SetSizeHints(dlg)
    dlg.sizer0.Layout()
    dlg.Center()
    result = dlg.ShowModal()
    dlg.Destroy()
    if parent and getattr(parent, "menuitem_app_auto_update_check", None):
        parent.menuitem_app_auto_update_check.Check(bool(getcfg("update_check")))
    if result == wx.ID_OK and (
        zeroinstall or (sys.platform in ("darwin", "win32") or argyll)
    ):
        worker = parent.worker if parent and hasattr(parent, "worker") else Worker()
        # Snapshot or Stable
        folder = "/snapshot" if snapshot else ""
        if zeroinstall:
            return
        elif not argyll:
            # Download the update from GitHub based on the user's platform
            download_url = get_download_url(newversion)
            if download_url is not None:
                try:
                    webbrowser.open_new_tab(download_url)
                except Exception as e:
                    print(f"Error opening web browser for updates: {str(e)}")
            else:
                launch_file(f"{DEVELOPMENT_HOME_PAGE}/releases")
        else:
            # Download ArgyllCMS or DisplayCAL update
            if argyll:
                consumer = worker.process_argyll_download
                argyll_domain = config.DEFAULTS.get("argyll.domain")
                machine = platform.machine().lower()
                if sys.platform == "win32":
                    # Determine 32 or 64 bit OS
                    key = winreg.OpenKey(
                        winreg.HKEY_LOCAL_MACHINE,
                        r"SYSTEM\CurrentControlSet\Control"
                        r"\Session Manager\Environment",
                    )
                    try:
                        value = winreg.QueryValueEx(key, "PROCESSOR_ARCHITECTURE")[0]
                    except OSError:
                        value = "x86"
                    finally:
                        winreg.CloseKey(key)
                    arch = value.lower()
                    if arch in ("arm64", "aarch64"):
                        suffix = "_win_arm64_exe.zip"
                    elif arch in ("amd64", "x86_64"):
                        suffix = "_win64_exe.zip"
                    else:
                        # Assume win32
                        suffix = "_win32_exe.zip"
                elif sys.platform == "darwin":
                    if machine in ("arm64", "aarch64"):
                        suffix = "_macOS11_arm64_bin.tgz"
                    else:
                        # Keep x86_64 as fallback on Intel and unknown machine ids.
                        suffix = "_osx10.6_x86_64_bin.tgz"
                # Linux
                elif (
                    machine in ("x86_64", "amd64")
                    or platform.architecture()[0] == "64bit"
                ):
                    # Assume x86_64
                    suffix = "_linux_x86_64_bin.tgz"
                else:
                    # Assume x86
                    suffix = "_linux_x86_bin.tgz"
                url = f"{argyll_domain}/releases/download/{newversion}/Argyll_V{newversion}{suffix}"
            else:
                consumer = worker.process_download
                # Snapshots are only available as ZIP
                # or Regular stable versions are available as setup
                if sys.platform == "win32":
                    suffix = "-win32.zip" if snapshot else "-Setup.exe"
                else:
                    suffix = ".dmg"
                url = f"https://{DOMAIN}/{folder}/{APPNAME}-{newversion}{suffix}"
            worker.start(
                consumer,
                worker.download,
                ckwargs={"exit_": not argyll},
                wargs=(url,),
                progress_msg=lang.getstr("downloading"),
                fancy=False,
            )
        return
    elif result != wx.ID_CANCEL:
        launch_file(DEVELOPMENT_HOME_PAGE)
    elif not argyll:
        # Check for Argyll update
        if check_argyll_bin():
            parent.app_update_check_handler(None, silent, True)
        elif silent:
            parent.set_argyll_bin_handler(
                True,
                silent,
                parent.check_instrument_setup,
                (check_donation, (parent, snapshot)),
            )
        else:
            parent.set_argyll_bin_handler(True)
        return
    if silent:
        # Check if we need to run instrument setup
        parent.check_instrument_setup(check_donation, (parent, snapshot))


def donation_message(parent: None | wx.Window = None) -> None:
    """Show donation message.

    Args:
        parent (None | wx.Window, optional): The parent window for the dialog.
    """
    dlg = ConfirmDialog(
        parent,
        title=lang.getstr("welcome"),
        msg=lang.getstr("donation_message"),
        ok=lang.getstr("contribute"),
        cancel=lang.getstr("not_now"),
        bitmap=get_bitmap("theme/headericon"),
        bitmap_margin=0,
    )
    header = wx.StaticText(dlg, -1, lang.getstr("donation_header"))
    font = header.Font
    font.PointSize += 4
    header.SetFont(font)
    if sys.platform != "darwin":
        header.MinSize = header.GetTextExtent(header.Label)
    dlg.sizer3.Insert(0, header, flag=wx.BOTTOM | wx.EXPAND, border=14)
    if sys.platform == "win32":
        font = dlg.message.Font
        font.PointSize += 1
        dlg.message.SetFont(font)
        dlg.message.MinSize = (-1, -1)
    chkbox = wx.CheckBox(dlg.buttonpanel, -1, lang.getstr("dialog.do_not_show_again"))
    dlg.sizer2.Insert(
        0,
        chkbox,
        flag=wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL | wx.RIGHT,
        border=max(dlg.sizer3.MinSize[0] - dlg.sizer2.MinSize[0] - chkbox.Size[0], 12),
    )
    dlg.sizer2.Insert(0, (88, -1))
    dlg.buttonpanel.Layout()
    dlg.sizer0.SetSizeHints(dlg)
    dlg.sizer0.Layout()
    if dlg.ShowModal() == wx.ID_OK:
        launch_file(f"https://{DOMAIN}/#donate")
        show_again = False
    else:
        show_again = not chkbox.Value
    setcfg("show_donation_message", int(show_again))
    dlg.Destroy()


def colorimeter_correction_web_check_choose(
    resp: bytes | str,
    parent: None | wx.Window = None,
) -> None | CGATS:
    """Let user choose a colorimeter correction and confirm overwrite.

    Args:
        resp (bytes | str): The response from the web request containing
            the colorimeter corrections in JSON format.
        parent (None | wx.Window, optional): The parent window for the dialog.

    Returns:
        None | CGATS: Returns a CGATS object if a correction is chosen,
            otherwise returns None.
    """
    if resp is not False:
        try:
            json = json_module.load(resp)
            if not json:
                raise ValueError
        except ValueError:
            InfoDialog(
                parent,
                msg=lang.getstr("colorimeter_correction.web_check.failure"),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-information"),
            )
            return None
    else:
        return None
    dlg = ConfirmDialog(
        parent,
        title=lang.getstr("colorimeter_correction.web_check"),
        msg=lang.getstr("colorimeter_correction.web_check.choose"),
        ok=lang.getstr("ok"),
        cancel=lang.getstr("cancel"),
        bitmap=get_icon(32, "dialog-information"),
        nowrap=True,
    )
    dlg.info = PlateButton(
        dlg.buttonpanel,
        -1,
        lang.getstr("colorimeter_correction.info"),
        get_icon(16, "info"),
    )
    set_platebutton_bitmaps(dlg.info, "info")
    dlg.sizer2.Insert(0, dlg.info, flag=wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=12)
    dlg.sizer2.Insert(0, (32 + 7, 1))
    scale = getcfg("app.dpi") / config.get_default_dpi()
    scale = max(scale, 1)
    dlg_list_ctrl = wx.ListCtrl(
        dlg,
        -1,
        size=(int(640 * scale), int(150 * scale)),
        style=wx.LC_REPORT | wx.LC_SINGLE_SEL,
        name="colorimeter_corrections",
    )
    col = IncrementingInt()
    dlg_list_ctrl.InsertColumn(int(col), lang.getstr("type"))
    dlg_list_ctrl.InsertColumn(int(col), lang.getstr("description"))
    dlg_list_ctrl.InsertColumn(int(col), lang.getstr("display"))
    # dlg_list_ctrl.InsertColumn(int(col), lang.getstr("instrument"))
    dlg_list_ctrl.InsertColumn(int(col), lang.getstr("reference"))
    dlg_list_ctrl.InsertColumn(int(col), lang.getstr("spectral_resolution"))
    dlg_list_ctrl.InsertColumn(int(col), lang.getstr("observer"))
    dlg_list_ctrl.InsertColumn(int(col), lang.getstr("method"))
    dlg_list_ctrl.InsertColumn(
        int(col), "ΔE*00 " + lang.getstr("profile.self_check.avg")
    )
    dlg_list_ctrl.InsertColumn(
        int(col), "ΔE*00 " + lang.getstr("profile.self_check.max")
    )
    dlg_list_ctrl.InsertColumn(int(col), lang.getstr("created"))
    col.i = 0
    dlg_list_ctrl.SetColumnWidth(int(col), int(75 * scale))  # Type
    dlg_list_ctrl.SetColumnWidth(int(col), int(415 * scale))  # Desc
    dlg_list_ctrl.SetColumnWidth(
        int(col), int(150 * scale)
    )  # Display manufacturer & model
    # dlg_list_ctrl.SetColumnWidth(int(col), int(225 * scale))  # Instrument
    dlg_list_ctrl.SetColumnWidth(int(col), int(90 * scale))  # Ref. instrument
    dlg_list_ctrl.SetColumnWidth(int(col), int(150 * scale))  # Spectral res
    dlg_list_ctrl.SetColumnWidth(int(col), int(135 * scale))  # Observer
    dlg_list_ctrl.SetColumnWidth(int(col), int(135 * scale))  # CCMX fit method
    dlg_list_ctrl.SetColumnWidth(int(col), int(135 * scale))  # CCMX self check avg
    dlg_list_ctrl.SetColumnWidth(int(col), int(135 * scale))  # CCMX self check max
    dlg_list_ctrl.SetColumnWidth(int(col), int(150 * scale))  # Date
    types = {
        "CCSS": lang.getstr("spectral").replace(":", ""),
        "CCMX": lang.getstr("matrix").replace(":", ""),
    }
    cgats = {}
    rows_data = []
    for item in json:
        # CGATS is byte string based, make sure to encode Unicode back to UTF-8
        # for parsing
        # CGATS accepts ``bytes`` data only
        cgats_bytes = item.get("cgats", "").encode("utf-8")
        try:
            ccxx = CGATS(cgats_bytes)
        except CGATSError as exception:
            print(exception)
            cgats_bytes = b""
            ccxx = CGATS()
        ccxx = ccxx.get(0, ccxx)
        ccxx_type = item.get("type", "").upper()
        manufacturer = colord.quirk_manufacturer(
            item.get("manufacturer") or lang.getstr("unknown")
        )
        display = item.get("display") or lang.getstr("unknown")
        if config.is_virtual_display(display):
            display = manufacturer
        if not display.lower().startswith(manufacturer.lower()):
            display = f"{manufacturer} {display}"
        spectral = {}
        for key in ("bands", "start_nm", "end_nm"):
            try:
                v = float(item.get(f"spectral_{key}", 0))
            except (TypeError, ValueError):
                pass
            else:
                if v:
                    spectral[key] = v
        if spectral:
            spectral_res = "{:.1f}nm, {:.0f}-{:.0f}nm".format(
                (spectral["end_nm"] - spectral["start_nm"]) / (spectral["bands"] - 1),
                spectral["start_nm"],
                spectral["end_nm"],
            )
        else:
            spectral_res = lang.getstr("unknown")
        created = item.get("created")
        if created:
            try:
                created = strptime(created)
            except ValueError:
                datetmp = re.search(
                    r"\w+ (\w{3}) (\d{2}) (\d{2}(?::[0-5][0-9]){2}) (\d{4})", created
                )
                if datetmp:
                    datetmp = "{}-{}-{} {}".format(
                        datetmp.groups()[3],
                        {
                            "Jan": "01",
                            "Feb": "02",
                            "Mar": "03",
                            "Apr": "04",
                            "May": "05",
                            "Jun": "06",
                            "Jul": "07",
                            "Aug": "08",
                            "Sep": "09",
                            "Oct": "10",
                            "Nov": "11",
                            "Dec": "12",
                        }.get(datetmp.groups()[0]),
                        datetmp.groups()[1],
                        datetmp.groups()[2],
                    )
                    with contextlib.suppress(ValueError):
                        created = strptime(datetmp, "%Y-%m-%d %H:%M:%S")
            if isinstance(created, struct_time):
                created = strftime("%Y-%m-%d %H:%M:%S", created)
        fit_method = ccxx.queryv1("FIT_METHOD")
        if fit_method and fit_method != b"xy":
            fit_method = lang.getstr("perceptual")
        elif isinstance(fit_method, bytes):
            # queryv1() returns bytes; decode so it's consistent with the
            # localized str the "perceptual" branch above produces.
            fit_method = fit_method.decode("utf-8")
        reference_observer = ccxx.queryv1("REFERENCE_OBSERVER")
        if isinstance(reference_observer, bytes):
            # queryv1() returns bytes, but observers_ab is keyed by str, so
            # without decoding this lookup never matches and the "observer"
            # column always fell back to "unknown"/"not_applicable".
            reference_observer = reference_observer.decode("utf-8")
        rows_data.append({
            "cgats": cgats_bytes,
            "columns": [
                types.get(ccxx_type, ccxx_type),
                get_canonical_instrument_name(
                    item.get("description") or lang.getstr("unknown")
                ),
                display,
                get_canonical_instrument_name(
                    item.get("reference") or lang.getstr("unknown")
                ),
                spectral_res,
                parent.observers_ab.get(
                    reference_observer,
                    lang.getstr("unknown" if ccxx_type == "CCMX" else "not_applicable"),
                ),
                (
                    fit_method or lang.getstr("unknown")
                    if ccxx_type == "CCMX"
                    else lang.getstr("not_applicable")
                ),
                (
                    str(ccxx.queryv1("FIT_AVG_DE00") or lang.getstr("unknown"))
                    if ccxx_type == "CCMX"
                    else lang.getstr("not_applicable")
                ),
                (
                    str(ccxx.queryv1("FIT_MAX_DE00") or lang.getstr("unknown"))
                    if ccxx_type == "CCMX"
                    else lang.getstr("not_applicable")
                ),
                created or lang.getstr("unknown"),
            ],
        })

    sort_state = [None, True]  # [column_index, ascending]

    def populate_list() -> None:
        dlg_list_ctrl.DeleteAllItems()
        for idx, row in enumerate(rows_data):
            dlg_list_ctrl.InsertStringItem(idx, "")
            for col_idx, val in enumerate(row["columns"]):
                dlg_list_ctrl.SetStringItem(idx, col_idx, val)
            cgats[idx] = row["cgats"]

    populate_list()

    def show_ccxx_info(event: wx.Event) -> None:
        """Show colorimeter correction info dialog.

        Args:
            event (wx.Event): The event that triggered this function.
        """
        index = dlg_list_ctrl.GetNextItem(-1, wx.LIST_NEXT_ALL, wx.LIST_STATE_SELECTED)
        parent.colorimeter_correction_info_handler(event, cgats[index])

    dlg.info.Bind(wx.EVT_BUTTON, show_ccxx_info)
    dlg.Bind(
        wx.EVT_LIST_ITEM_SELECTED,
        lambda event: (dlg.ok.Enable(), dlg.info.Enable()),
        dlg_list_ctrl,
    )
    dlg.Bind(
        wx.EVT_LIST_ITEM_DESELECTED,
        lambda event: (dlg.ok.Disable(), dlg.info.Disable()),
        dlg_list_ctrl,
    )
    dlg.Bind(
        wx.EVT_LIST_ITEM_ACTIVATED, lambda event: dlg.EndModal(wx.ID_OK), dlg_list_ctrl
    )

    def on_col_click(event: wx.ListEvent) -> None:
        clicked_col = event.GetColumn()
        if sort_state[0] == clicked_col:
            sort_state[1] = not sort_state[1]
        else:
            sort_state[0] = clicked_col
            sort_state[1] = True

        def _sort_key(val: str) -> tuple:
            try:
                return (0, float(val), "")
            except ValueError:
                return (1, 0.0, val.lower())

        rows_data.sort(
            key=lambda r: _sort_key(r["columns"][clicked_col]),
            reverse=not sort_state[1],
        )
        populate_list()
        dlg.ok.Disable()
        dlg.info.Disable()

    dlg_list_ctrl.Bind(wx.EVT_LIST_COL_CLICK, on_col_click)
    dlg.sizer3.Add(dlg_list_ctrl, 1, flag=wx.TOP | wx.ALIGN_LEFT, border=12)
    lstr = lang.getstr("colorimeter_correction.web_check.info")
    lstr_en = lang.getstr("colorimeter_correction.web_check.info", lcode="en")
    if lstr != lstr_en or lang.getcode() == "en":
        info_txt = wx.StaticText(dlg, -1, lstr)
        info_txt.Wrap(int(640 * scale))
        dlg.sizer3.Add(info_txt, 1, flag=wx.TOP | wx.ALIGN_LEFT, border=12)
    if len(cgats) > 1:
        # We got several matches
        dlg.ok.Disable()
        dlg.info.Disable()
    else:
        item = dlg_list_ctrl.GetItem(0)
        dlg_list_ctrl.SetItemState(
            item.GetId(), wx.LIST_STATE_SELECTED, wx.LIST_STATE_SELECTED
        )
    dlg.sizer0.SetSizeHints(dlg)
    dlg.sizer0.Layout()
    dlg.Center()
    result = dlg.ShowWindowModalBlocking()
    index = dlg_list_ctrl.GetNextItem(-1, wx.LIST_NEXT_ALL, wx.LIST_STATE_SELECTED)
    dlg.Destroy()
    if result != wx.ID_OK:
        return False
    # Important: Do not use parsed CGATS,
    # order of keywords may be
    # different from raw data so MD5 will be different
    return colorimeter_correction_check_overwrite(parent, cgats[index])


def colorimeter_correction_check_overwrite(
    parent: wx.Window = None,
    cgats: None | bytes = None,
    update_comports: bool = False,
) -> bool:
    """Prompt to confirm overwriting a colorimeter correction file if it exists.

    Write the file.

    Args:
        parent (wx.Window): The parent window to show dialogs.
        cgats (None | bytes, optional): The CGATS data to write.
        update_comports (bool, optional): Whether to update the comports after
            writing.

    Returns:
        bool: True if the file was written successfully, False otherwise.
    """
    result = check_create_dir(config.get_argyll_data_dir())
    if isinstance(result, Exception):
        show_result_dialog(result, parent)
        return None
    path = get_cgats_path(cgats)
    if os.path.isfile(path):
        dlg = ConfirmDialog(
            parent,
            msg=lang.getstr("dialog.confirm_overwrite", path),
            ok=lang.getstr("ok"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, "dialog-warning"),
        )
        result = dlg.ShowWindowModalBlocking()
        dlg.Destroy()
        if result != wx.ID_OK:
            return False
    try:
        with open(path, "wb") as cgatsfile:
            cgatsfile.write(cgats.rstrip(b"\n") + b"\n")
    except OSError as exception:
        show_result_dialog(exception, parent)
        return False
    if getcfg("colorimeter_correction_matrix_file").split(":")[0] != "AUTO":
        setcfg("colorimeter_correction_matrix_file", ":" + path)
    if update_comports:
        cgats = CGATS(cgats)
        instrument = cgats.queryv1("INSTRUMENT") or getcfg(
            "colorimeter_correction.instrument"
        )
        if instrument:
            instrument = get_canonical_instrument_name(instrument)
    else:
        instrument = None
    if instrument and instrument in parent.worker.instruments:
        setcfg("comport.number", parent.worker.instruments.index(instrument) + 1)
        parent.update_comports(force=True)
    else:
        parent.update_colorimeter_correction_matrix_ctrl_items(True)
    return True


def get_cgats_measurement_mode(cgats: bytes, instrument: str) -> str:
    """Get the measurement mode for the CGATS data.

    Args:
        cgats (bytes): The CGATS data.
        instrument (str): The instrument name.

    Returns:
        str: The measurement mode.
    """
    base_id = cgats.queryv1("DISPLAY_TYPE_BASE_ID")
    refresh = cgats.queryv1("DISPLAY_TYPE_REFRESH")
    mode = None
    if base_id:
        # IMPORTANT: Make changes aswell in the following locations:
        # - DisplayCAL.MainFrame.create_colorimeter_correction_handler
        # - DisplayCAL.MainFrame.get_ccxx_measurement_modes
        # - DisplayCAL.MainFrame.set_ccxx_measurement_mode
        # - worker.Worker.check_add_display_type_base_id
        # - worker.Worker.instrument_can_use_ccxx
        if instrument in ("ColorHug", "ColorHug2"):
            mode = {1: "F", 2: "R"}.get(base_id)
        elif instrument == "ColorMunki Smile":
            mode = {1: "f"}.get(base_id)
        elif instrument == "Colorimtre HCFR":
            mode = {1: "R"}.get(base_id)
        elif instrument == "K-10":
            mode = {1: "F"}.get(base_id)
        else:
            mode = {1: "l", 2: "c", 3: "g"}.get(base_id)
    elif refresh == b"NO":
        mode = "l"
    elif refresh == b"YES":
        mode = "c"
    return mode


def get_cgats_path(cgats: bytes) -> str:
    """Get the path to save the CGATS file.

    Args:
        cgats (bytes): The CGATS data.

    Returns:
        str: The path to save the CGATS file.
    """
    descriptor = re.search(rb'\nDESCRIPTOR\s+"(.+?)"\n', cgats)
    if descriptor:
        descriptor = descriptor.groups()[0]
    descriptor = descriptor.decode("utf-8")
    description = descriptor or lang.getstr("unnamed")
    name = re.sub(r"[\\/:;*?\"<>|]+", "_", make_argyll_compatible_path(description))[
        :255
    ]
    extension = cgats.split()[0].lower().decode("utf-8")
    return os.path.join(config.get_argyll_data_dir(), f"{name}.{extension}")


def get_header(
    parent: wx.Window,
    bitmap: None | wx.Bitmap = None,
    label: None | str = None,
    size: tuple[int, int] = (-1, 64),
    x: int = 80,
    y: int = 44,
    repeat_sub_bitmap_h: tuple[int, int, int, int] = (220, 0, 2, 64),
) -> wx.Panel:
    """Create a header panel with a bitmap and label.

    Args:
        parent (wx.Window): The parent window.
        bitmap (None | wx.Bitmap, optional): The bitmap to display.
        label (None | str, optional): The label to display.
        size (tuple, optional): The size of the header panel.
        x (int, optional): The x position of the label.
        y (int, optional): The y position of the label.
        repeat_sub_bitmap_h (tuple, optional): The sub-bitmap height for
            repeating.

    Returns:
        wx.Panel: The header panel.
    """
    w, h = 222, 64
    scale = getcfg("app.dpi") / config.get_default_dpi()
    if scale > 1:
        size = tuple(math.floor(v * scale) if v > 0 else v for v in size)
        x, y = [round(v * scale) if v else v for v in (x, y)]
        repeat_sub_bitmap_h = tuple(math.floor(v * scale) for v in repeat_sub_bitmap_h)
        w, h = [round(v * scale) for v in (w, h)]
    header = BitmapBackgroundPanelText(parent)
    header.label_x = x
    header.label_y = y
    header.scalebitmap = (False,) * 2
    header.textshadow = False
    header.SetBackgroundColour("#0e59a9")
    header.SetForegroundColour("#FFFFFF")
    header.SetMaxFontSize(11)
    label = label or lang.getstr("header")
    if not bitmap:
        bitmap = get_bitmap("theme/header", display_missing_icon=False)
        if bitmap.Size[0] >= w and bitmap.Size[1] >= h:
            bitmap = bitmap.GetSubBitmap((0, 0, w, h))
    header.MinSize = size
    header.repeat_sub_bitmap_h = repeat_sub_bitmap_h
    header.SetBitmap(bitmap)
    header.SetLabel(label)
    return header


def get_profile_load_on_login_label(os_cal: bool) -> str:
    """Get the label for the profile load on login checkbox.

    Args:
        os_cal (bool): True if the OS calibration is active.

    Returns:
        str: The label for the profile load on login checkbox.
    """
    label = lang.getstr("profile.load_on_login")
    if sys.platform == "win32" and not os_cal:
        lstr = lang.getstr("calibration.preserve")
        if lang.getcode() != "de":
            lstr = lstr[0].lower() + lstr[1:]
        label += " && " + lstr
    return label


def upload_colorimeter_correction(
    parent: None | wx.Window = None, params: None | dict = None
) -> None:
    """Upload colorimeter correction to online database.

    Args:
        parent (None | wx.Window): The parent window.
        params (None | dict): The parameters for the upload.
    """
    path = "/index.php"
    failure_msg = lang.getstr("colorimeter_correction.upload.failure")
    # Check for duplicate
    resp = http_request(
        parent,
        f"colorimetercorrections.{DOMAIN}",
        "GET",
        path,
        # Remove CREATED date for calculating hash
        {
            "get": True,
            "hash": md5(  # noqa: S324
                re.sub(
                    rb'\nCREATED\s+".+?"\n', rb"\n\n", bytes(params["cgats"])
                ).strip()
            ).hexdigest(),
        },
        silent=True,
    )
    if resp and resp.read().strip().startswith(b"CC"):
        wx.CallAfter(
            InfoDialog,
            parent,
            msg=lang.getstr("colorimeter_correction.upload.exists"),
            ok=lang.getstr("ok"),
            bitmap=get_icon(32, "dialog-information"),
        )
        return
    # Upload
    params["put"] = True
    resp = http_request(
        parent,
        f"colorimetercorrections.{DOMAIN}",
        "POST",
        path,
        params,
        failure_msg=failure_msg,
    )
    if resp is not False:
        if resp.status == 201:
            wx.CallAfter(
                InfoDialog,
                parent,
                msg=lang.getstr("colorimeter_correction.upload.success"),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-information"),
            )
        else:
            wx.CallAfter(
                InfoDialog,
                parent,
                msg=b"\n\n".join([failure_msg, resp.read().strip()]),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )


def install_scope_handler(
    event: None | wx.Event = None, dlg: None | wx.Dialog = None
) -> None:
    """Enable/disable the install systemwide button and the ok button.

    Args:
        event (wx.Event, optional): The event that triggered this function.
        dlg (wx.Dialog, optional): The dialog to update. If None, uses the
            TopLevelParent of the event.
    """
    dlg = dlg or event.EventObject.TopLevelParent
    auth_needed = dlg.install_systemwide.GetValue()
    if hasattr(dlg.ok, "SetAuthNeeded"):
        dlg.ok.SetAuthNeeded(auth_needed)
        if hasattr(dlg, "alt"):
            dlg.alt.SetAuthNeeded(auth_needed)
    dlg.buttonpanel.Layout()


def webbrowser_open(url: str, new: bool = False) -> bool:
    """Open a URL in the web browser.

    Args:
        url (str): The URL to open.
        new (bool, optional): If True, open in a new browser window or tab.
            Defaults to False.

    Returns:
        bool: True if the URL was opened successfully, False otherwise.
    """
    try:
        webbrowser.open(url, new=new)
        return True
    except Exception as exception:
        show_result_dialog(exception)
        return False


class Dummy:
    """Useful if we need an object to attach arbitrary attributes."""


class IncrementingInt:
    """A integer that increments by `step` each time it is used.

    Args:
        start (int): The starting value of the integer.
        stop (int, optional): The value at which to stop incrementing. Defaults
            to None.
        step (int): The amount to increment by each time. Defaults to 1.
    """

    def __init__(self, start: int = 0, stop: None | int = None, step: int = 1) -> None:
        self.i = start
        self.stop = stop
        self.step = step

    def __int__(self) -> int:
        """Return the current value and increment it by `step`.

        Returns:
            int: The current value before incrementing.
        """
        i = self.i
        if self.stop is None or self.i < self.stop:
            self.i += self.step
        return i


class ExtraArgsFrame(BaseFrame):
    """Extra commandline arguments window.

    Args:
        parent (wx.Window): The parent window for the ExtraArgsFrame.
    """

    def __init__(self, parent: wx.Window) -> None:
        self.res = TempXmlResource(get_data_path(os.path.join("xrc", "extra.xrc")))
        self.res.InsertHandler(xh_floatspin.FloatSpinCtrlXmlHandler())
        self.res.InsertHandler(xh_hstretchstatbmp.HStretchStaticBitmapXmlHandler())
        self.res.InsertHandler(xh_bitmapctrls.BitmapButton())
        self.res.InsertHandler(xh_bitmapctrls.StaticBitmap())
        if hasattr(wx, "PreFrame"):
            # Classic
            pre = wx.PreFrame()
            self.res.LoadOnFrame(pre, parent, "extra_args")
            self.PostCreate(pre)
        else:
            # Phoenix
            wx.Frame.__init__(self)
            self.res.LoadFrame(self, parent, "extra_args")
        self.init()
        self.Bind(wx.EVT_CLOSE, self.OnClose, self)

        self.SetIcons(config.get_icon_bundle([256, 48, 32, 16], APPNAME))

        self.set_child_ctrls_as_attrs(self)

        child = self.environment_label
        font = child.Font
        font.SetWeight(wx.FONTWEIGHT_BOLD)
        child.Font = font

        # Bind event handlers
        self.Bind(
            wx.EVT_TEXT,
            self.extra_args_handler,
            id=self.extra_args_dispcal_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_TEXT,
            self.extra_args_handler,
            id=self.extra_args_dispread_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_TEXT,
            self.extra_args_handler,
            id=self.extra_args_spotread_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_TEXT,
            self.extra_args_handler,
            id=self.extra_args_specplot_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_TEXT,
            self.extra_args_handler,
            id=self.extra_args_colprof_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_TEXT,
            self.extra_args_handler,
            id=self.extra_args_collink_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_TEXT, self.extra_args_handler, id=self.extra_args_targen_ctrl.GetId()
        )

        self.setup_language()
        self.update_controls()

    def OnClose(self, event: wx.Event) -> None:  # noqa: N802
        """Handle the close event for the ExtraArgsFrame.

        Args;
            event (wx.Event): The close event.
        """
        self.Hide()

    def extra_args_handler(self, event: wx.Event) -> None:
        """Handle changes to the extra arguments controls.

        Args:
            event (wx.Event): The event triggered by a control change.
        """
        mapping = {
            self.extra_args_dispcal_ctrl.GetId(): "extra_args.dispcal",
            self.extra_args_dispread_ctrl.GetId(): "extra_args.dispread",
            self.extra_args_spotread_ctrl.GetId(): "extra_args.spotread",
            self.extra_args_specplot_ctrl.GetId(): "extra_args.specplot",
            self.extra_args_colprof_ctrl.GetId(): "extra_args.colprof",
            self.extra_args_collink_ctrl.GetId(): "extra_args.collink",
            self.extra_args_targen_ctrl.GetId(): "extra_args.targen",
        }
        pref = mapping.get(event.GetId())
        if pref:
            ctrl = self.FindWindowById(event.GetId())
            value = ctrl.GetValue()
            setcfg(pref, value)

    def update_controls(self) -> None:
        """Update the controls with the current configuration values."""
        self.extra_args_dispcal_ctrl.ChangeValue(getcfg("extra_args.dispcal"))
        self.extra_args_dispread_ctrl.ChangeValue(getcfg("extra_args.dispread"))
        self.extra_args_spotread_ctrl.ChangeValue(getcfg("extra_args.spotread"))
        self.extra_args_specplot_ctrl.ChangeValue(getcfg("extra_args.specplot"))
        self.extra_args_colprof_ctrl.ChangeValue(getcfg("extra_args.colprof"))
        self.extra_args_collink_ctrl.ChangeValue(getcfg("extra_args.collink"))
        self.extra_args_targen_ctrl.ChangeValue(getcfg("extra_args.targen"))
        self.Sizer.SetSizeHints(self)
        self.Sizer.Layout()


class GamapFrame(BaseFrame):
    """Gamut mapping options window.

    Args:
        parent (wx.Window): The parent window for the gamut mapping options.
    """

    def __init__(self, parent: wx.Window) -> None:
        self.res = TempXmlResource(get_data_path(os.path.join("xrc", "gamap.xrc")))
        self.res.InsertHandler(
            xh_filebrowsebutton.FileBrowseButtonWithHistoryXmlHandler()
        )
        self.res.InsertHandler(xh_hstretchstatbmp.HStretchStaticBitmapXmlHandler())
        self.res.InsertHandler(xh_bitmapctrls.BitmapButton())
        self.res.InsertHandler(xh_bitmapctrls.StaticBitmap())
        if hasattr(wx, "PreFrame"):
            # Classic
            pre = wx.PreFrame()
            self.res.LoadOnFrame(pre, parent, "gamapframe")
            self.PostCreate(pre)
        else:
            # Phoenix
            wx.Frame.__init__(self)
            self.res.LoadFrame(self, parent, "gamapframe")
        self.init()
        self.Bind(wx.EVT_CLOSE, self.OnClose, self)

        self.SetIcons(config.get_icon_bundle([256, 48, 32, 16], APPNAME))

        self.panel = xrc.XRCCTRL(self, "panel")

        self.set_child_ctrls_as_attrs(self)

        child = self.gamut_mapping_ciecam02_label
        font = child.Font
        font.SetWeight(wx.FONTWEIGHT_BOLD)
        child.Font = font

        self.gamap_profile = xrc.XRCCTRL(self, "gamap_profile")
        self.gamap_profile.changeCallback = self.gamap_profile_handler
        self.gamap_profile.SetHistory(get_data_path("ref", r"\.(icm|icc)$"))
        self.gamap_profile.SetMaxFontSize(11)
        self.droptarget = FileDrop(self)
        self.droptarget.drophandlers = {
            ".icc": self.drop_handler,
            ".icm": self.drop_handler,
        }
        self.gamap_profile.SetDropTarget(self.droptarget)

        # Bind event handlers
        self.Bind(
            wx.EVT_CHECKBOX,
            self.gamap_perceptual_cb_handler,
            id=self.gamap_perceptual_cb.GetId(),
        )
        self.Bind(
            wx.EVT_CHOICE,
            self.gamap_perceptual_intent_handler,
            id=self.gamap_perceptual_intent_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_CHECKBOX,
            self.gamap_saturation_cb_handler,
            id=self.gamap_saturation_cb.GetId(),
        )
        self.Bind(
            wx.EVT_CHOICE,
            self.gamap_saturation_intent_handler,
            id=self.gamap_saturation_intent_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_CHOICE,
            self.gamap_src_viewcond_handler,
            id=self.gamap_src_viewcond_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_CHOICE,
            self.gamap_out_viewcond_handler,
            id=self.gamap_out_viewcond_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_CHOICE,
            self.gamap_default_intent_handler,
            id=self.gamap_default_intent_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_CHECKBOX,
            self.profile_quality_b2a_ctrl_handler,
            id=self.low_quality_b2a_cb.GetId(),
        )
        self.Bind(
            wx.EVT_CHECKBOX,
            self.profile_quality_b2a_ctrl_handler,
            id=self.b2a_hires_cb.GetId(),
        )
        for v in config.VALID_VALUES["profile.b2a.hires.size"]:
            v = f"{v}x{v}x{v}" if v > -1 else lang.getstr("auto")
            self.b2a_size_ctrl.Append(v)
        self.Bind(
            wx.EVT_CHOICE, self.b2a_size_ctrl_handler, id=self.b2a_size_ctrl.GetId()
        )
        self.Bind(
            wx.EVT_CHECKBOX,
            self.profile_quality_b2a_ctrl_handler,
            id=self.b2a_smooth_cb.GetId(),
        )

        self.viewconds_ab = {}
        self.viewconds_ba = {}
        self.viewconds_out_ab = {}

        self.intents_ab = {}
        self.intents_ba = {}

        self.default_intent_ab = {}
        self.default_intent_ba = {}
        for i, ri in enumerate(config.VALID_VALUES["gamap_default_intent"]):
            self.default_intent_ab[i] = ri
            self.default_intent_ba[ri] = i

        self.setup_language()
        self.update_controls()
        self.update_layout()

    def OnClose(self, event: wx.Event) -> None:  # noqa: N802
        """Handle the close event for the gamut mapping options window.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        self.Hide()

    def b2a_size_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the B2A size control.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        v = config.VALID_VALUES["profile.b2a.hires.size"][
            self.b2a_size_ctrl.GetSelection()
        ]
        if (
            v != getcfg("profile.b2a.hires.size")
            and self.Parent
            and hasattr(self.Parent, "profile_settings_changed")
        ):
            self.Parent.profile_settings_changed()
        setcfg("profile.b2a.hires.size", v)

    def drop_handler(self, path: str) -> None:
        """Handle dropping a file onto the gamut mapping profile control.

        Args:
            path (str): The path of the dropped file.
        """
        self.gamap_profile.SetPath(path)
        self.gamap_profile_handler(True)

    def gamap_profile_handler(self, event: None | wx.Event = None) -> None:
        """Handle changes to the gamut mapping profile.

        Args:
            event (None | wx.Event): The event that triggered the handler.
        """
        v = self.gamap_profile.GetPath()
        p = bool(v) and os.path.exists(v)
        c = self.gamap_perceptual_cb.GetValue() or self.gamap_saturation_cb.GetValue()
        if p and c:
            try:
                profile = ICCProfile(v)
            except (OSError, ICCProfileInvalidError):
                p = False
                InfoDialog(
                    self,
                    msg=lang.getstr("profile.invalid") + "\n" + v,
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                self.gamap_profile.SetPath("")
                v = None
            else:
                src_viewcond = getcfg("gamap_src_viewcond")
                if event and (
                    (
                        src_viewcond in [None, *self.viewconds_out_nondisplay]
                        and profile.profileClass in (b"mntr", b"spac")
                    )
                    or (
                        src_viewcond not in self.viewconds_out_nondisplay
                        and profile.profileClass not in (b"mntr", b"spac")
                    )
                ):
                    # pre-select suitable viewing condition
                    src_viewcond = "pp" if profile.profileClass == b"prtr" else "mt"
                    self.gamap_src_viewcond_ctrl.SetStringSelection(
                        lang.getstr("gamap.viewconds." + src_viewcond)
                    )
                    self.gamap_src_viewcond_handler()
                    if not self.gamap_out_viewcond_ctrl.Selection:
                        current_profile = get_current_profile(True)
                        if current_profile:
                            if current_profile.profileClass == b"prtr":
                                out_viewcond = "pp"
                            else:
                                out_viewcond = "mt"
                            self.gamap_out_viewcond_ctrl.SetStringSelection(
                                lang.getstr("gamap.viewconds." + out_viewcond)
                            )
                            self.gamap_out_viewcond_handler()
        enable_gamap = getcfg("profile.type") in ("l", "x", "X")
        self.gamap_perceptual_cb.Enable(enable_gamap)
        self.gamap_perceptual_intent_ctrl.Enable(self.gamap_perceptual_cb.GetValue())
        self.gamap_saturation_cb.Enable(enable_gamap)
        self.gamap_saturation_intent_ctrl.Enable(self.gamap_saturation_cb.GetValue())
        self.gamap_profile.Enable(c)
        self.gamap_src_viewcond_ctrl.Enable(p and c)
        self.gamap_out_viewcond_ctrl.Enable(p and c)
        if not ((p and c) or getcfg("profile.b2a.hires")):
            setcfg("gamap_default_intent", "p")
        self.gamap_default_intent_ctrl.SetSelection(
            self.default_intent_ba[getcfg("gamap_default_intent")]
        )
        self.gamap_default_intent_ctrl.Enable(
            (p and c) or (getcfg("profile.b2a.hires") and enable_gamap)
        )
        if (
            v != getcfg("gamap_profile")
            and self.Parent
            and hasattr(self.Parent, "profile_settings_changed")
        ):
            self.Parent.profile_settings_changed()
        setcfg("gamap_profile", v or None)

    def gamap_perceptual_cb_handler(self, event: None | wx.Event = None) -> None:
        """Handle changes to the perceptual checkbox.

        Args:
            event (None | wx.Event): The event that triggered the handler.
        """
        v = self.gamap_perceptual_cb.GetValue()
        if not v:
            self.gamap_saturation_cb.SetValue(False)
            self.gamap_saturation_cb_handler()
        if (
            int(v) != getcfg("gamap_perceptual")
            and self.Parent
            and hasattr(self.Parent, "profile_settings_changed")
        ):
            self.Parent.profile_settings_changed()
        setcfg("gamap_perceptual", int(v))
        self.gamap_profile_handler(event)

    def gamap_perceptual_intent_handler(self, event: None | wx.Event = None) -> None:
        """Handle changes to the perceptual intent control.

        Args:
            event (None | wx.Event): The event that triggered the handler.
        """
        v = self.intents_ba[self.gamap_perceptual_intent_ctrl.GetStringSelection()]
        if (
            v != getcfg("gamap_perceptual_intent")
            and self.Parent
            and hasattr(self.Parent, "profile_settings_changed")
        ):
            self.Parent.profile_settings_changed()
        setcfg("gamap_perceptual_intent", v)

    def gamap_saturation_cb_handler(self, event: None | wx.Event = None) -> None:
        """Handle changes to the saturation checkbox.

        Args:
            event (None | wx.Event): The event that triggered the handler.
        """
        perc = self.gamap_perceptual_cb.GetValue()
        v = self.gamap_saturation_cb.GetValue()
        if v:
            self.gamap_perceptual_cb.SetValue(True)
            self.gamap_perceptual_cb_handler()
        if (
            int(v) != getcfg("gamap_saturation")
            and self.Parent
            and hasattr(self.Parent, "profile_settings_changed")
        ):
            self.Parent.profile_settings_changed()
        setcfg("gamap_saturation", int(v))
        self.gamap_profile_handler(event and not perc)

    def gamap_saturation_intent_handler(self, event: None | wx.Event = None) -> None:
        """Handle changes to the saturation intent control.

        Args:
            event (None | wx.Event): The event that triggered the handler.
        """
        v = self.intents_ba[self.gamap_saturation_intent_ctrl.GetStringSelection()]
        if (
            v != getcfg("gamap_saturation_intent")
            and self.Parent
            and hasattr(self.Parent, "profile_settings_changed")
        ):
            self.Parent.profile_settings_changed()
        setcfg("gamap_saturation_intent", v)

    def gamap_src_viewcond_handler(self, event: None | wx.Event = None) -> None:
        """Handle changes to the source viewing condition control.

        Args:
            event (None | wx.Event): The event that triggered the handler.
        """
        v = self.viewconds_ba[self.gamap_src_viewcond_ctrl.GetStringSelection()]
        if (
            v != getcfg("gamap_src_viewcond")
            and self.Parent
            and hasattr(self.Parent, "profile_settings_changed")
        ):
            self.Parent.profile_settings_changed()
        setcfg("gamap_src_viewcond", v)

    def gamap_out_viewcond_handler(self, event: None | wx.Event = None) -> None:
        """Handle changes to the output viewing condition control.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        lstr = self.gamap_out_viewcond_ctrl.GetStringSelection()
        cur = getcfg("gamap_out_viewcond")
        v = self.viewconds_ba[lstr]
        if v != cur and event and v in self.viewconds_out_nondisplay:
            if not show_result_dialog(
                Warn(lang.getstr("warning.gamap.out_viewcond.nondisplay", lstr)),
                self,
                confirm=lang.getstr("ok"),
            ):
                self.gamap_out_viewcond_ctrl.SetStringSelection(self.viewconds_ab[cur])
                return
            setcfg("gamap_out_viewcond", v)
            if self.Parent and hasattr(self.Parent, "profile_settings_changed"):
                self.Parent.profile_settings_changed()

    def gamap_default_intent_handler(self, event: None | wx.Event = None) -> None:
        """Handle changes to the default intent control.

        Args:
            event (None | wx.Event): The event that triggered the handler.
        """
        v = self.gamap_default_intent_ctrl.GetSelection()
        if (
            self.default_intent_ab[v] != getcfg("gamap_default_intent")
            and self.Parent
            and hasattr(self.Parent, "profile_settings_changed")
        ):
            self.Parent.profile_settings_changed()
        setcfg("gamap_default_intent", self.default_intent_ab[v])

    def profile_quality_b2a_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the B2A quality controls.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        if (
            event.GetId() == self.low_quality_b2a_cb.GetId()
            and self.low_quality_b2a_cb.GetValue()
        ):
            self.b2a_hires_cb.Enable(False)
        else:
            self.b2a_hires_cb.Enable(getcfg("profile.type") in ("l", "x", "X"))
        hires = self.b2a_hires_cb.GetValue()
        self.low_quality_b2a_cb.Enable(not hires)
        if hires:
            if event.GetId() == self.b2a_smooth_cb.GetId():
                setcfg("profile.b2a.hires.smooth", int(self.b2a_smooth_cb.GetValue()))
            else:
                self.b2a_smooth_cb.SetValue(bool(getcfg("profile.b2a.hires.smooth")))
        else:
            self.b2a_smooth_cb.SetValue(False)
        v = "l" if self.low_quality_b2a_cb.GetValue() else None
        if (
            v != getcfg("profile.quality.b2a") or hires != getcfg("profile.b2a.hires")
        ) and self.Parent:
            self.Parent.profile_settings_changed()
        setcfg("profile.quality.b2a", v)
        setcfg("profile.b2a.hires", int(hires))
        self.b2a_size_ctrl.Enable(hires)
        self.b2a_smooth_cb.Enable(hires)
        self.gamap_profile_handler()
        if self.Parent:
            self.Parent.update_bpc()
            self.Parent.lut3d_update_b2a_controls()
            if hasattr(self.Parent, "lut3dframe"):
                self.Parent.lut3dframe.update_controls()

    def setup_language(self) -> None:
        """Substitute translated strings for menus, controls, labels and tooltips."""
        BaseFrame.setup_language(self)

        self.gamap_profile.dialogTitle = lang.getstr("gamap.profile")
        self.gamap_profile.fileMask = lang.getstr("filetype.icc") + "|*.icc;*.icm"

        intents = list(config.INTENTS)
        if (
            self.Parent
            and hasattr(self.Parent, "worker")
            and self.Parent.worker.argyll_version < [1, 3, 3]
        ):
            intents.remove("pa")
        if (
            self.Parent
            and hasattr(self.Parent, "worker")
            and self.Parent.worker.argyll_version < [1, 8, 3]
        ):
            intents.remove("lp")
        for v in intents:
            lstr = lang.getstr(f"gamap.intents.{v}")
            self.intents_ab[v] = lstr
            self.intents_ba[lstr] = v

        self.gamap_perceptual_intent_ctrl.SetItems(list(self.intents_ab.values()))
        self.gamap_saturation_intent_ctrl.SetItems(list(self.intents_ab.values()))

        self.viewconds_ab[None] = lang.getstr("none")
        self.viewconds_ba[lang.getstr("none")] = None
        self.viewconds_out_nondisplay = ["pp", "pe", "pc", "pcd", "ob", "cx"]
        # if False:
        #     # NEVER - filter dest viewing conditions
        #     self.viewconds_out_ignore = self.viewconds_out_nondisplay
        # else:
        viewconds_out_ignore = []

        for v in VIEWCONDS:
            if (
                self.Parent
                and hasattr(self.Parent, "worker")
                and (
                    (v == "pc" and self.Parent.worker.argyll_version < [1, 1, 1])
                    or (v == "tv" and self.Parent.worker.argyll_version < [1, 6, 0])
                )
            ):
                continue
            lstr = lang.getstr(f"gamap.viewconds.{v}")
            self.viewconds_ab[v] = lstr
            self.viewconds_ba[lstr] = v
            if v not in viewconds_out_ignore:
                self.viewconds_out_ab[v] = lstr

        self.gamap_src_viewcond_ctrl.SetItems(list(self.viewconds_ab.values()))
        self.gamap_out_viewcond_ctrl.SetItems(
            [lang.getstr("none"), *self.viewconds_out_ab.values()]
        )

        self.gamap_default_intent_ctrl.SetItems(
            [
                lang.getstr("gamap.intents." + v)
                for v in config.VALID_VALUES["gamap_default_intent"]
            ]
        )

    def update_controls(self) -> None:
        """Update controls with values from the configuration."""
        # B2A quality
        enable_gamap = getcfg("profile.type") in ("l", "x", "X")
        enable_b2a_extra = getcfg("profile.type") in ("l", "x", "X")
        b2a_hires = enable_b2a_extra and bool(getcfg("profile.b2a.hires"))
        self.low_quality_b2a_cb.SetValue(
            enable_gamap
            and getcfg("profile.quality.b2a") in ("l", "n")
            and not b2a_hires
        )
        self.low_quality_b2a_cb.Enable(enable_gamap and not b2a_hires)
        self.b2a_hires_cb.SetValue(b2a_hires)
        self.b2a_hires_cb.Enable(
            enable_b2a_extra and not self.low_quality_b2a_cb.GetValue()
        )
        self.b2a_size_ctrl.SetSelection(
            config.VALID_VALUES["profile.b2a.hires.size"].index(
                getcfg("profile.b2a.hires.size")
            )
        )
        self.b2a_size_ctrl.Enable(b2a_hires)
        self.b2a_smooth_cb.SetValue(
            b2a_hires and bool(getcfg("profile.b2a.hires.smooth"))
        )
        self.b2a_smooth_cb.Enable(b2a_hires)

        # CIECAM02
        self.gamap_profile.SetPath(getcfg("gamap_profile"))
        self.gamap_perceptual_cb.SetValue(
            enable_gamap and bool(getcfg("gamap_perceptual"))
        )
        self.gamap_perceptual_intent_ctrl.SetStringSelection(
            self.intents_ab.get(
                getcfg("gamap_perceptual_intent"),
                self.intents_ab.get(DEFAULTS["gamap_perceptual_intent"]),
            )
        )
        self.gamap_saturation_cb.SetValue(
            enable_gamap and bool(getcfg("gamap_saturation"))
        )
        self.gamap_saturation_intent_ctrl.SetStringSelection(
            self.intents_ab.get(
                getcfg("gamap_saturation_intent"),
                self.intents_ab.get(DEFAULTS["gamap_saturation_intent"]),
            )
        )
        self.gamap_src_viewcond_ctrl.SetStringSelection(
            self.viewconds_ab.get(
                getcfg("gamap_src_viewcond", False),
                self.viewconds_ab.get(DEFAULTS.get("gamap_src_viewcond")),
            )
        )
        self.gamap_out_viewcond_ctrl.SetStringSelection(
            self.viewconds_ab.get(
                getcfg("gamap_out_viewcond"),
                self.viewconds_ab.get(DEFAULTS.get("gamap_out_viewcond")),
            )
        )

        self.gamap_profile_handler()


class MainFrame(ReportFrame, BaseFrame, LUT3DMixin):
    """Display calibrator main application window.

    Args:
        worker (Worker): The worker instance to handle background tasks.
    """

    def __init__(self, worker: Worker) -> None:
        # XYZbpout will be set to the blackpoint of the selected profile. This is
        # used to determine if 3D LUT or measurement report black output offset
        # controls should be shown. Set an initial value slightly above zero so
        # output offset controls are shown if the selected profile doesn't exist
        # and "Create 3D LUT after profiling" is disabled.
        self.XYZbpout = [0.001, 0.001, 0.001]
        # Check for required resource files and get pre-canned testcharts
        self.dist_testcharts = []
        self.dist_testchart_names = []
        self.menubar = None
        missing = []
        for filename in RES_FILES:
            path, ext = (
                get_data_path(os.path.sep.join(filename.split("/"))),
                os.path.splitext(filename)[1],
            )
            if not path or not os.path.isfile(path):
                missing.append(filename)
            elif ext.lower() == ".ti1":
                self.dist_testcharts.append(path)
                self.dist_testchart_names.append(os.path.basename(path))
        if missing:
            wx.CallAfter(
                show_result_dialog,
                lang.getstr("resources.notfound.warning")
                + "\n"
                + str("\n".join(missing)),
                self,
            )

        # Initialize GUI
        self.res = TempXmlResource(get_data_path(os.path.join("xrc", "main.xrc")))
        self.res.InsertHandler(xh_fancytext.StaticFancyTextCtrlXmlHandler())
        self.res.InsertHandler(xh_floatspin.FloatSpinCtrlXmlHandler())
        self.res.InsertHandler(xh_hstretchstatbmp.HStretchStaticBitmapXmlHandler())
        self.res.InsertHandler(xh_bitmapctrls.BitmapButton())
        self.res.InsertHandler(xh_bitmapctrls.StaticBitmap())
        if hasattr(wx, "PreFrame"):
            # Classic
            pre = wx.PreFrame()
            self.res.LoadOnFrame(pre, None, "mainframe")
            self.PostCreate(pre)
        else:
            # Phoenix
            wx.Frame.__init__(self)
            self.res.LoadFrame(self, None, "mainframe")
        self.init()
        self.worker = worker
        self.worker.owner = self
        result = self.worker.create_tempdir()
        if isinstance(result, Exception):
            print(result)
        self.init_frame()
        self.init_defaults()
        self.set_child_ctrls_as_attrs(self)
        self.init_infoframe()
        self.init_measureframe()
        self.init_menus()
        self.init_controls()
        self.show_advanced_options_handler()
        self.setup_language()
        self.update_displays(update_ccmx_items=False)
        self.update_comports()
        self.mr_init_controls()
        # If a previous session crashed during a CCXX measurement, the config
        # retains testchart.file set to the CCXX testchart and backup keys set,
        # causing is_ccxx_testchart() to return True and hiding the correction
        # section. Restore backed-up values before update_controls() runs.
        self.restore_measurement_mode()
        self.restore_testchart()
        self.update_controls(update_ccmx_items=False)
        scrollrate_x = 2 if self.calpanel.VirtualSize[0] > self.calpanel.Size[0] else 0
        self.calpanel.SetScrollRate(scrollrate_x, 2)
        x, y = getcfg("position.x", False), getcfg("position.y", False)
        if None not in (x, y):
            self.SetSaneGeometry(x, y)
        self.set_size(True, True)
        if None in (x, y):
            self.Center()
        self.Bind(wx.EVT_MOVE, self.OnMove, self)
        verbose_print(lang.getstr("success"))

        # Check for and load default calibration
        if len(self.worker.displays):
            if getcfg("calibration.file", False):
                # Load LUT curves from last used .cal file
                self.load_cal(silent=True)
            else:
                # Load LUT curves from current display profile (if any, and
                # if it contains curves)
                self.load_display_profile_cal(None)

        self.init_timers()
        verbose_print(lang.getstr("ready"))

    def log(self) -> None:
        """Append log buffer contents to the log window."""
        # We do this after all initialization because the log.log() function
        # expects the window to be fully created and accessible via
        # wx.GetApp().frame.infoframe
        if not hasattr(self, "logoffset"):
            # Skip the very first line, which is just '=' * 80
            self.logoffset = 1
        else:
            self.logoffset = 0
        LOGBUFFER.seek(0)
        msg = "".join(
            [line.decode("UTF-8", "replace") for line in LOGBUFFER][self.logoffset :]
        ).rstrip()
        LOGBUFFER.truncate(0)
        if msg:
            self.infoframe.Log(msg)

    def init_defaults(self) -> None:
        """Initialize GUI-specific defaults."""
        DEFAULTS.update(
            {
                "position.info.x": self.GetDisplay().ClientArea[0] + 30,
                "position.info.y": self.GetDisplay().ClientArea[1] + 30,
                "position.lut_viewer.x": self.GetDisplay().ClientArea[0] + 40,
                "position.lut_viewer.y": self.GetDisplay().ClientArea[1] + 40,
                "position.progress.x": self.GetDisplay().ClientArea[0] + 30,
                "position.progress.y": self.GetDisplay().ClientArea[1] + 30,
                "position.x": self.GetDisplay().ClientArea[0] + 20,
                "position.y": self.GetDisplay().ClientArea[1] + 20,
            }
        )

        self.recent_cals = getcfg("recent_cals").split(os.pathsep)
        while "" in self.recent_cals:
            self.recent_cals.remove("")
        self.recent_cals.insert(0, "")

        self.presets = []
        presets = get_data_path("presets", r".*\.(?:icc|icm)$")
        if isinstance(presets, list):
            self.presets = natsort(presets)
            self.presets.reverse()
            for preset in self.presets:
                if preset in self.recent_cals:
                    self.recent_cals.remove(preset)
                self.recent_cals.insert(1, preset)

        self.static_labels = []

        self.updatingctrls = False

        # Left side - internal enumeration, right side - commmandline
        self.whitepoint_colortemp_loci_ab = {0: "t", 1: "T"}

        # Left side - commmandline, right side - internal enumeration
        self.whitepoint_colortemp_loci_ba = {"t": 0, "T": 1}

        # Left side - commmandline, right side - internal enumeration
        self.quality_ab = {1: "v", 2: "l", 3: "m", 4: "h", 5: "u"}
        self.quality_b2a_ab = {0: "n", 1: "l", 2: "m", 3: "h", 4: "u"}

        # Left side - commmandline, right side - internal enumeration
        self.quality_ba = swap_dict_keys_values(self.quality_ab)

        self.testchart_defaults = config.TESTCHART_DEFAULTS
        self.testcharts = []
        self.testchart_names = []

        # Left side - commmandline, right side - .cal file
        self.trc_ab = {"l": "L_STAR", "709": "REC709", "s": "sRGB", "240": "SMPTE240M"}

        # Left side - .cal file, right side - commmandline
        self.trc_ba = swap_dict_keys_values(self.trc_ab)

        # Left side - internal enumeration, right side - commmandline
        self.trc_types_ab = {0: "g", 1: "G"}

        # Left side - commmandline, right side - internal enumeration
        self.trc_types_ba = swap_dict_keys_values(self.trc_types_ab)

        self.trc_presets = ["1.8", "2.0", "2.2", "2.4"]

        self.whitepoint_presets = ["5000", "5500", "6000", "6500"]

    def init_frame(self) -> None:
        """Initialize the main window and its event handlers.

        Controls are initialized in a separate step (see init_controls).

        """
        # UGLY HACK: This 'print' call fixes a GTK assertion and
        # segfault under Arch Linux when setting the window title
        print("")
        title = f"{APPNAME} {VERSION_STRING}"
        self.SetTitle(title)
        self.SetMaxSize((-1, -1))
        self.SetIcons(config.get_icon_bundle([256, 48, 32, 16], APPNAME))
        self.Bind(wx.EVT_CLOSE, self.OnClose, self)
        self.Bind(wx.EVT_SIZE, self.OnResize, self)
        self.Bind(wx.EVT_DISPLAY_CHANGED, self.check_update_controls)
        self.droptarget = FileDrop(self)
        self.droptarget.drophandlers = {
            ".7z": self.cal_drop_handler,
            ".cal": self.cal_drop_handler,
            ".ccmx": self.ccxx_drop_handler,
            ".ccss": self.ccxx_drop_handler,
            ".icc": self.cal_drop_handler,
            ".icm": self.cal_drop_handler,
            ".tar.gz": self.cal_drop_handler,
            ".ti1": self.ti1_drop_handler,
            ".ti3": self.ti3_drop_handler,
            ".tgz": self.cal_drop_handler,
            ".zip": self.cal_drop_handler,
        }

        # Main panel
        self.panel = xrc.XRCCTRL(self, "panel")
        self.panel.SetDropTarget(self.droptarget)

        # Header
        # Its width also determines the initial min width of the main window
        # after SetSizeHints and Layout
        self.headerbordertop = xrc.XRCCTRL(self, "headerbordertop")
        self.header = get_header(self.panel)
        self.headerpanel = xrc.XRCCTRL(self, "headerpanel")
        self.headerpanel.ContainingSizer.Insert(1, self.header, flag=wx.EXPAND)
        y = 64
        w = 80
        h = 120
        scale = max(getcfg("app.dpi") / config.get_default_dpi(), 1)
        if scale > 1:
            y, w, h = [math.floor(v * scale) for v in (y, w, h)]
        self.header_btm = BitmapBackgroundPanel(self.headerpanel, size=(w, -1))
        self.header_btm.BackgroundColour = "#0e59a9"
        self.header_btm.scalebitmap = False, False
        header_bmp = get_bitmap("theme/header", display_missing_icon=False)
        if header_bmp.Size[0] >= w and header_bmp.Size[1] >= h + y:
            header_bmp = header_bmp.GetSubBitmap((0, y, w, h))
            self.header_btm.SetBitmap(header_bmp)
        self.headerpanel.Sizer.Insert(0, self.header_btm, flag=wx.ALIGN_TOP | wx.EXPAND)
        # separator = BitmapBackgroundPanel(self.panel, size=(-1, 1))
        # separator.SetBackgroundColour(
        #     wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DSHADOW)
        # )
        # self.panel.Sizer.Insert(2, separator, flag=wx.EXPAND)

        # Calibration settings panel
        self.calpanel = xrc.XRCCTRL(self, "calpanel")
        self.display_instrument_panel = xrc.XRCCTRL(self, "display_instrument_panel")
        self.calibration_settings_panel = xrc.XRCCTRL(
            self, "calibration_settings_panel"
        )
        self.profile_settings_panel = xrc.XRCCTRL(self, "profile_settings_panel")
        self.lut3d_settings_panel = xrc.XRCCTRL(self, "lut3d_settings_panel")

        # Verification / measurement report
        res = TempXmlResource(get_data_path(os.path.join("xrc", "report.xrc")))
        res.InsertHandler(xh_fancytext.StaticFancyTextCtrlXmlHandler())
        res.InsertHandler(xh_filebrowsebutton.FileBrowseButtonWithHistoryXmlHandler())
        res.InsertHandler(xh_hstretchstatbmp.HStretchStaticBitmapXmlHandler())
        res.InsertHandler(xh_bitmapctrls.BitmapButton())
        res.InsertHandler(xh_bitmapctrls.StaticBitmap())
        self.mr_settings_panel = res.LoadPanel(self.calpanel, "panel")
        self.calpanel.Sizer.__init__()  # fixes #507
        self.calpanel.Sizer.Add(self.mr_settings_panel, 1, flag=wx.EXPAND)

        # Make info panels use theme color
        for panel_name in [
            "display_instrument_info_panel",
            "calibration_settings_info_panel",
            "profile_settings_info_panel",
            "lut3d_settings_info_panel",
            "mr_settings_info_panel",
        ]:
            panel = xrc.XRCCTRL(self, panel_name)
            panel.BackgroundColour = wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOW)
            for child in panel.Children:
                if isinstance(child, wx.Panel):
                    child.BackgroundColour = panel.BackgroundColour
            setattr(self, panel_name, panel)

        # Show display type help
        btn = PlateButton(
            self.display_instrument_info_panel,
            -1,
            "info.display_tech.show",
            get_icon(16, "info"),
        )
        set_platebutton_bitmaps(btn, "info")
        self.display_instrument_info_panel.Sizer.Add((0, int(14 * scale)))
        self.display_instrument_info_panel.Sizer.Add(
            btn, flag=wx.LEFT, border=int((16 + 32 + 7) * scale)
        )
        self.display_instrument_info_panel.Sizer.Add((0, int(12 * scale)))
        self.display_tech_info_show_btn = btn

        # Button panel
        self.buttonpanel = xrc.XRCCTRL(self, "buttonpanel")
        sizer = self.buttonpanel.ContainingSizer
        if hasattr(sizer, "GetItemIndex"):
            # wxPython 2.8.12+
            # separator = BitmapBackgroundPanel(self.panel, size=(-1, 1))
            # separator.SetBackgroundColour(
            #     wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DLIGHT)
            # )
            # sizer.Insert(sizer.GetItemIndex(self.buttonpanel), separator,
            # flag=wx.EXPAND)
            self.buttonpanelheader = BitmapBackgroundPanel(
                self.panel, size=(-1, int(15 * scale))
            )
            # bmp = get_bitmap("theme/gradient", False)
            bmp = get_bitmap("theme/shadow-bordertop", display_missing_icon=False)
            # if bmp.Size[0] >= 8 and bmp.Size[1] >= 96:
            # bmp = bmp.GetSubBitmap(
            #     (0, 1, 8, 15)
            # ).ConvertToImage().Mirror(False).ConvertToBitmap()
            # image = bmp.ConvertToImage()
            # databuffer = image.GetDataBuffer()
            # for i, byte in enumerate(databuffer):
            # if byte > "\0":
            # databuffer[i] = chr(int(min(round(ord(byte) *
            # (255.0 / 223.0)), 255)))
            # bmp = image.ConvertToBitmap()
            self.buttonpanelheader.SetBitmap(bmp)
            sizer.Insert(
                sizer.GetItemIndex(self.buttonpanel),
                self.buttonpanelheader,
                flag=wx.EXPAND,
            )
            # bgcolor = self.buttonpanel.BackgroundColour
            # self.buttonpanel.SetBackgroundColour(wx.Colour(*[int(v * .93)
            # for v in bgcolor[:3]]))
            self.buttonpanel.SetBackgroundColour(self.buttonpanel.BackgroundColour)
            self.buttonpanelheader.SetBackgroundColour(
                self.buttonpanel.BackgroundColour
            )
            self.buttonpanelheader.blend = True

        # Tab panel
        self.tabpanel = xrc.XRCCTRL(self, "tabpanel")
        sizer = self.tabpanel.ContainingSizer
        if hasattr(sizer, "GetItemIndex"):
            # wxPython 2.8.12+
            # separator = BitmapBackgroundPanel(self.panel, size=(-1, 1))
            # separator.SetBackgroundColour(
            #     wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DSHADOW)
            # )
            # sizer.Insert(sizer.GetItemIndex(self.tabpanel) + 1, separator,
            # flag=wx.EXPAND)
            # self.tabpanelheader = BitmapBackgroundPanel(self.panel,
            # size=(-1, 15))
            self.tabpanelheader = BitmapBackgroundPanel(self.panel, size=(-1, 14))
            # self.tabpanelfooter = BitmapBackgroundPanel(self.panel,
            # size=(-1, 15))
            # bmp = get_bitmap("theme/gradient", False)
            # if bmp.Size[0] >= 8 and bmp.Size[1] >= 96:
            # sub = bmp.GetSubBitmap((0, 1, 8, 15)).ConvertToImage()
            # bmp = sub.Mirror(False).ConvertToBitmap()
            # image2 = bmp.ConvertToImage()
            # databuffer = image2.GetDataBuffer()
            # for i, byte in enumerate(databuffer):
            # if byte > "\0":
            # databuffer[i] = chr(int(min(round((ord(byte) - 153) *
            # (255.0 / 70.0)), 255)))
            # bmp = image2.ConvertToBitmap()
            # self.tabpanelheader.SetBitmap(bmp)
            # bmp = image.Mirror(False).ConvertToBitmap()
            # self.tabpanelfooter.SetBitmap(bmp)
            sizer.Insert(
                sizer.GetItemIndex(self.tabpanel), self.tabpanelheader, flag=wx.EXPAND
            )
            # sizer.Insert(sizer.GetItemIndex(self.tabpanel) + 1,
            # self.tabpanelfooter, flag=wx.EXPAND)
            self.tabpanel.BackgroundColour = "#202020"
            self.tabpanel.ForegroundColour = "#EEEEEE"
            self.tabpanelheader.SetBackgroundColour(self.tabpanel.BackgroundColour)
            self.tabpanelheader.blend = True
            # self.tabpanelfooter.SetBackgroundColour(self.tabpanel.BackgroundColour)
            # self.tabpanelfooter.blend = True

        # Add tab buttons
        self.display_instrument_btn = TabButton(
            self.tabpanel,
            -1,
            label="display-instrument",
            bmp=get_icon(32, "display-instrument"),
            style=platebtn.PB_STYLE_TOGGLE,
        )
        self.display_instrument_btn.Bind(wx.EVT_TOGGLEBUTTON, self.tab_select_handler)
        self.tabpanel.Sizer.Insert(
            1, self.display_instrument_btn, flag=wx.LEFT, border=16
        )
        self.calibration_settings_btn = TabButton(
            self.tabpanel,
            -1,
            label="calibration",
            bmp=get_icon(32, "calibration"),
            style=platebtn.PB_STYLE_TOGGLE,
        )
        self.calibration_settings_btn.Bind(wx.EVT_TOGGLEBUTTON, self.tab_select_handler)
        self.tabpanel.Sizer.Insert(
            2, self.calibration_settings_btn, flag=wx.LEFT, border=32
        )
        self.profile_settings_btn = TabButton(
            self.tabpanel,
            -1,
            label="profiling",
            bmp=get_icon(32, "profiling"),
            style=platebtn.PB_STYLE_TOGGLE,
        )
        self.profile_settings_btn.Bind(wx.EVT_TOGGLEBUTTON, self.tab_select_handler)
        self.tabpanel.Sizer.Insert(
            3, self.profile_settings_btn, flag=wx.LEFT, border=32
        )
        self.lut3d_settings_btn = TabButton(
            self.tabpanel,
            -1,
            label="3dlut",
            bmp=get_icon(32, "3dlut"),
            style=platebtn.PB_STYLE_TOGGLE,
        )
        self.lut3d_settings_btn.Bind(wx.EVT_TOGGLEBUTTON, self.tab_select_handler)
        self.tabpanel.Sizer.Insert(
            4, self.lut3d_settings_btn, flag=wx.LEFT | wx.RIGHT, border=32
        )
        self.mr_settings_btn = TabButton(
            self.tabpanel,
            -1,
            label="verification",
            bmp=get_icon(32, "dialog-ok"),
            style=platebtn.PB_STYLE_TOGGLE,
        )
        self.mr_settings_btn.Bind(wx.EVT_TOGGLEBUTTON, self.tab_select_handler)
        self.tabpanel.Sizer.Insert(5, self.mr_settings_btn, flag=wx.RIGHT, border=16)
        for btn in (
            self.display_instrument_btn,
            self.calibration_settings_btn,
            self.profile_settings_btn,
            self.lut3d_settings_btn,
            self.mr_settings_btn,
        ):
            set_bitmap_labels(btn, True, False, False)
            btn.SetPressColor(wx.Colour(0x66, 0x66, 0x66))
            btn.SetLabelColor(self.tabpanel.ForegroundColour, wx.WHITE)
        self.tab_select_handler(self.display_instrument_btn)

        self.profile_info = {}
        self.measureframes = []
        self.ccxx_plot_windows = {}

    def init_timers(self) -> None:
        """Setup the timers for display/instrument detection and profile name."""
        self.update_profile_name_timer = wx.Timer(self)
        self.Bind(
            wx.EVT_TIMER, self.update_profile_name, self.update_profile_name_timer
        )

        # Global key handler
        self._key_is_down = None
        self.check_keydown_timer = wx.Timer(self)
        self.Bind(wx.EVT_TIMER, self.check_keydown, self.check_keydown_timer)

        # macOS only: watch for the OS clobbering the VideoLUT to black after a
        # calibration was loaded/installed (issue #694) and re-load it. Only
        # fires while idle (not measuring), so it can't disturb a measurement.
        self.lut_watch_timer = wx.Timer(self)
        self.Bind(wx.EVT_TIMER, self.check_lut_clobbered, self.lut_watch_timer)

    def check_keydown(self, event: wx.TimerEvent) -> None:
        """Check if the ALT key is pressed and update the measurement report button.

        Args:
            event (wx.TimerEvent): The timer event triggered by the timer.
        """
        if self._key_is_down != wx.WXK_ALT and wx.GetKeyState(wx.WXK_ALT):
            self._key_is_down = wx.WXK_ALT
            self.measurement_report_btn.Label = lang.getstr("self_check_report")
            self.measurement_report_btn.Refresh()
        elif self._key_is_down == wx.WXK_ALT and not wx.GetKeyState(wx.WXK_ALT):
            self._key_is_down = None
            self.measurement_report_btn.Label = lang.getstr("measurement_report")
            self.measurement_report_btn.Refresh()

    def OnMove(self, event: wx.Event = None) -> None:  # noqa: N802
        """Handle the window move event.

        Args:
            event (wx.Event, optional): The move event triggered by the user.
        """
        # When moving, check if we are on another screen and resize if needed.
        if self.IsShownOnScreen() and not self.IsMaximized() and not self.IsIconized():
            x, y = self.GetScreenPosition()
            setcfg("position.x", x)
            setcfg("position.y", y)
            display_client_rect = self.GetDisplay().ClientArea
            if (
                getattr(self, "display_client_rect", display_client_rect)
                != display_client_rect
            ):
                # We just moved to this workspace
                if sys.platform not in ("darwin", "win32"):
                    # Linux
                    if os.getenv("XDG_SESSION_TYPE") == "wayland":
                        # Client-side decorations
                        safety_margin = 0
                    else:
                        # Assume server-side decorations
                        safety_margin = 40
                else:
                    safety_margin = 20
                resize = False
                if (
                    self.Size[0] > display_client_rect[2]
                    or self.Size[1] > display_client_rect[3] - safety_margin
                ):
                    # Our size is too large for that workspace, adjust
                    resize = True
                elif self.Size[0] < (
                    self.Size[0] - self.calpanel.Size[0] + self.calpanel.VirtualSize[0]
                ) or self.Size[1] < (
                    self.Size[1] - self.calpanel.Size[1] + self.calpanel.VirtualSize[1]
                ):
                    # Our full size fits on that workspace
                    resize = True
                if resize:
                    wx.CallAfter(self.set_size, True)
            self.display_client_rect = display_client_rect
        if event:
            event.Skip()

    def OnResize(self, event: wx.Event) -> None:  # noqa: N802
        """Handle the window resize event.

        Args:
            event (wx.Event): The resize event triggered by the user.
        """
        # Hide the header bitmap on small screens
        scale = getcfg("app.dpi") / config.get_default_dpi()
        scale = max(scale, 1)
        self.header.GetContainingSizer().Show(self.header, self.Size[1] > 480 * scale)
        if not hasattr(self, "header_btm_bmp"):
            self.header_btm_bmp = self.header_btm.GetBitmap()
            self.header_btm_min_bmp = get_bitmap(
                "theme/header_minimal", display_missing_icon=False
            )
        if self.Size[1] > 480 * scale:
            if self.header_btm.GetBitmap() is not self.header_btm_bmp:
                self.header_btm.SetBitmap(self.header_btm_bmp)
        elif self.header_btm.GetBitmap() is not self.header_btm_min_bmp:
            self.header_btm.SetBitmap(self.header_btm_min_bmp)
        event.Skip()

    def cal_drop_handler(self, path: str) -> None:
        """Handle drag'n'drop for .cal files.

        Settings and calibration are loaded from dropped files.

        Args:
            path (str): The path to the dropped .cal file.
        """
        if not self.worker.is_working():
            self.load_cal_handler(None, path)

    def ccxx_drop_handler(self, path: str) -> None:
        """Handle drag'n'drop for .ccmx/.ccss files.

        Args:
            path (str): The path to the dropped .ccmx or .ccss file.
        """
        if not self.worker.is_working():
            self.colorimeter_correction_matrix_ctrl_handler(None, path)

    def ti1_drop_handler(self, path: str) -> None:
        """Handle drag'n'drop for .ti1 files.

        Dropped files are added to the testchart chooser and selected.

        Args:
            path (str): The path to the dropped .ti1 file.
        """
        if not self.worker.is_working():
            self.testchart_btn_handler(None, path)

    def ti3_drop_handler(self, path: str) -> None:
        """Handle drag'n'drop for .ti3 files.

        Dropped files are used to create an ICC profile.

        Args:
            path (str): The path to the dropped .ti3 file.
        """
        if not self.worker.is_working():
            self.create_profile_handler(None, path)

    def init_gamapframe(self) -> None:
        """Create & initialize the gamut mapping options window and its controls."""
        self.gamapframe = GamapFrame(self)

    def init_infoframe(self, show: None | bool = None) -> None:
        """Create & initialize the info (log) window and its controls.

        Args:
            show (None | bool, optional): Whether to show the info frame
                immediately. Defaults to None, which means it will not be shown
                unless the user has set the "show_info_frame" configuration
                option to True.
        """
        self.infoframe = LogWindow(self)
        self.infoframe.Bind(wx.EVT_CLOSE, self.infoframe_close_handler, self.infoframe)
        self.infoframe.SetIcons(config.get_icon_bundle([256, 48, 32, 16], APPNAME))
        if show:
            self.infoframe_toggle_handler(show=show)

    def init_lut3dframe(self) -> None:
        """Create & initialize the 3D LUT creation window and its controls."""
        self.lut3dframe = LUT3DFrame(self)

    def init_reportframe(self) -> None:
        """Initialize the measurement report creation window."""
        self.reportframe = ReportFrame(self)
        self.reportframe.measurement_report_btn.Bind(
            wx.EVT_BUTTON, self.measurement_report_handler
        )

    def init_synthiccframe(self) -> None:
        """Create & initialize the 3D LUT creation window and its controls."""
        # Avoid messing with main configuration (e.g. when not running standalone)
        # because we share HDR settings with 3D LUT HDR settings
        SynthICCFrame.cfg = config.configparser.RawConfigParser()
        config.initcfg("synthprofile", SynthICCFrame.cfg)
        self.synthiccframe = SynthICCFrame()

    def infoframe_close_handler(self, event: wx.Event) -> None:
        """Handle the info frame close event.

        Args:
            event (wx.Event): The close event triggered by the user.
        """
        self.infoframe_toggle_handler(event)

    def setup_language(self) -> None:
        """Substitute translated strings for menus, controls, labels and tooltips."""
        # Set language specific defaults
        lang.update_defaults()

        # Translate controls and labels
        BaseFrame.setup_language(self)

        settings = [lang.getstr("settings.new")]
        for cal in self.recent_cals[1:]:
            lstr = lang.getstr(os.path.basename(cal))
            if cal == getcfg("calibration.file", False) and getcfg("settings.changed"):
                lstr = "* " + lstr
            settings.append(lstr)
        self.calibration_file_ctrl.SetItems(settings)
        self.setup_observer_ctrl()
        self.whitepoint_ctrl.SetItems(
            [
                lang.getstr("as_measured"),
                lang.getstr("whitepoint.colortemp"),
                lang.getstr("whitepoint.xy"),
            ]
        )

        self.whitepoint_colortemp_loci = [
            lang.getstr("whitepoint.colortemp.locus.daylight"),
            lang.getstr("whitepoint.colortemp.locus.blackbody"),
        ]
        self.whitepoint_colortemp_locus_ctrl.SetItems(self.whitepoint_colortemp_loci)

        self.luminance_ctrl.SetItems(
            [lang.getstr("as_measured"), lang.getstr("custom")]
        )

        self.black_luminance_ctrl.SetItems(
            [lang.getstr("as_measured"), lang.getstr("custom")]
        )

        self.trc_ctrl.SetItems(
            [
                lang.getstr("as_measured"),
                "Gamma 2.2",
                lang.getstr("trc.lstar"),
                lang.getstr("trc.rec709"),
                lang.getstr("trc.rec1886"),
                lang.getstr("trc.smpte240m"),
                lang.getstr("trc.srgb"),
                lang.getstr("custom"),
            ]
        )

        self.trc_types = [
            lang.getstr("trc.type.relative"),
            lang.getstr("trc.type.absolute"),
        ]
        self.trc_type_ctrl.SetItems(self.trc_types)

        self.update_profile_type_ctrl_items()

        self.default_testchart_names = []
        for testcharts in list(self.testchart_defaults.values()):
            for chart in list(testcharts.values()):
                chart = lang.getstr(chart)
                if chart not in self.default_testchart_names:
                    self.default_testchart_names.append(chart)

        items = [
            lang.getstr("testchart." + v)
            for v in config.VALID_VALUES["testchart.patch_sequence"]
        ]
        self.testchart_patch_sequence_ctrl.Items = items

        self.lut3d_setup_language()
        self.mr_setup_language()

    def get_min_height(self) -> int:
        """Calculate minimum panel height.

        Returns:
            int: The minimum required height.
        """
        if sys.platform not in ("darwin", "win32"):
            # Linux
            # Client-side decorations in Wayland, server-side decorations in X11
            safety_margin = 0 if os.getenv("XDG_SESSION_TYPE") == "wayland" else 40
        else:
            safety_margin = 20

        # Use display size
        display_area_free_height = (
            self.GetDisplay().ClientArea[3]
            + self.ClientSize[1]
            - self.Size[1]
            - safety_margin
        )

        # Use element heights
        header_border_top_height = self.headerbordertop.Size[1]
        header_height = self.header.Size[1]
        header_panel_height = self.headerpanel.Sizer.MinSize[1]
        tab_panel_header_height = 0
        tab_panel_height = self.tabpanel.Sizer.MinSize[1]
        tab_panel_footer_height = 0
        display_instrument_panel = self.display_instrument_panel.Sizer.MinSize[1]
        button_panel_header_height = 0
        button_panel_height = self.buttonpanel.Sizer.MinSize[1]
        if getattr(self, "tabpanelheader", None):
            tab_panel_header_height = self.tabpanelheader.Size[1] + 1

        if getattr(self, "tabpanelfooter", None):
            tab_panel_footer_height = self.tabpanelfooter.Size[1] + 1

        if getattr(self, "buttonpanelheader", None):
            button_panel_header_height = self.buttonpanelheader.Size[1] + 1

        per_element_heights = (
            header_border_top_height
            + header_height
            + header_panel_height
            + 1
            + tab_panel_header_height
            + tab_panel_height
            + tab_panel_footer_height
            + display_instrument_panel
            + button_panel_header_height
            + button_panel_height
        )

        return min(
            display_area_free_height,
            per_element_heights,
        )

    def set_size(self, set_height: bool = False, fit_width: bool = False) -> None:
        """Set the size of the main window.

        Args:
            set_height (bool, optional): If True, set the height to the minimum
                required height.
            fit_width (bool, optional): If True, adjust the width to fit the
                content. If False, keep the current width.
        """
        self.SetMinSize((0, 0))
        borders_tb = self.Size[1] - self.ClientSize[1]
        height = self.get_min_height() if set_height else self.ClientSize[1]
        borders_lr = self.Size[0] - self.ClientSize[0]
        scale = getcfg("app.dpi") / config.get_default_dpi()
        margin = wx.SystemSettings.GetMetric(wx.SYS_VSCROLL_X)
        header_min_h = 64
        if scale > 1:
            header_min_h = round(header_min_h * scale)
        self.mr_settings_panel.Freeze()
        sim_show = self.simulation_profile_cb.IsShown()
        self.simulation_profile_cb.Show()
        devlink_show = self.devlink_profile_cb.IsShown()
        self.devlink_profile_cb.Show()
        size = (
            min(
                max(self.GetDisplay().ClientArea[2] - borders_lr, 0),
                max(
                    max(
                        self.display_instrument_panel.Sizer.MinSize[0],
                        self.calibration_settings_panel.Sizer.MinSize[0],
                        self.profile_settings_panel.Sizer.MinSize[0],
                        self.lut3d_settings_panel.Sizer.MinSize[0],
                        self.mr_settings_panel.Sizer.MinSize[0],
                    )
                    + margin,
                    self.tabpanel.GetSizer().GetMinSize()[0],
                ),
            ),
            height,
        )
        self.simulation_profile_cb.Show(sim_show)
        self.devlink_profile_cb.Show(devlink_show)
        self.mr_settings_panel.Thaw()
        self.SetMaxSize((-1, -1))
        if not self.IsMaximized() and not self.IsIconized():
            self.ClientSize = (
                size[0] if fit_width else max(size[0], self.ClientSize[0]),
                size[1],
            )
        minsize = (
            self.ClientSize[0],
            self.ClientSize[1] - self.calpanel.GetSize()[1] + header_min_h,
        )
        if hasattr(self, "MinClientSize"):
            self.MinClientSize = minsize
        else:
            self.MinSize = (minsize[0] + borders_lr, minsize[1] + borders_tb)
        if os.getenv("XDG_SESSION_TYPE") == "wayland":
            self.MaxSize = self.Size
            wx.CallAfter(set_maxsize, self, (-1, -1))
        if self.IsShown():
            self.calpanel.Layout()

    def update_profile_type_ctrl(self) -> None:
        """Update the profile type control based on the current Argyll version."""
        self.profile_type_ctrl.SetSelection(
            self.profile_types_ba.get(
                getcfg("profile.type"),
                self.profile_types_ba.get(DEFAULTS["profile.type"], 0),
            )
        )

    def update_profile_type_ctrl_items(self) -> None:
        """Update profile type choices based on Argyll version."""
        self.profile_types = [
            lang.getstr("profile.type.lut.lab"),
            lang.getstr("profile.type.shaper_matrix"),
            lang.getstr("profile.type.single_shaper_matrix"),
            lang.getstr("profile.type.gamma_matrix"),
            lang.getstr("profile.type.single_gamma_matrix"),
        ]
        self.profile_types_ab = {}
        profile_types_index = 0
        if self.worker.argyll_version[0:3] > [1, 1, 0] or (
            self.worker.argyll_version[0:3] == [1, 1, 0]
            and "Beta" not in self.worker.argyll_version_string
            and "RC1" not in self.worker.argyll_version_string
            and "RC2" not in self.worker.argyll_version_string
            and "RC3" not in self.worker.argyll_version_string
        ):
            # Argyll 1.1.0_RC3 had a bug when using -aX
            # which was fixed in 1.1.0_RC4
            self.profile_types.insert(
                profile_types_index, lang.getstr("profile.type.lut_matrix.xyz")
            )
            # XYZ LUT + accurate matrix
            self.profile_types_ab[profile_types_index] = "X"
            profile_types_index += 1
        if ([1, 1, 0] < self.worker.argyll_version[0:3] < [2, 0, 2]) or (
            self.worker.argyll_version[0:3] == [1, 1, 0]
            and "Beta" not in self.worker.argyll_version_string
            and "RC1" not in self.worker.argyll_version_string
            and "RC2" not in self.worker.argyll_version_string
        ):
            # Windows wants matrix tags in XYZ LUT profiles,
            # which is satisfied with Argyll >= 1.1.0_RC3
            self.profile_types.insert(
                profile_types_index,
                lang.getstr("profile.type.lut_rg_swapped_matrix.xyz"),
            )
            # XYZ LUT + dummy matrix (R <-> G swapped)
            self.profile_types_ab[profile_types_index] = "x"
            profile_types_index += 1
        else:
            self.profile_types.insert(
                profile_types_index, lang.getstr("profile.type.lut.xyz")
            )
            self.profile_types_ab[profile_types_index] = "x"  # XYZ LUT
            profile_types_index += 1
        self.profile_type_ctrl.SetItems(self.profile_types)
        self.profile_types_ab[profile_types_index] = "l"
        self.profile_types_ab[profile_types_index + 1] = "s"
        self.profile_types_ab[profile_types_index + 2] = "S"
        self.profile_types_ab[profile_types_index + 3] = "g"
        self.profile_types_ab[profile_types_index + 4] = "G"
        self.profile_types_ba = swap_dict_keys_values(self.profile_types_ab)

    def init_measureframe(self) -> None:
        """Create & initialize the measurement window and its controls."""
        self.measureframe = MeasureFrame(self, -1)

    def init_menus(self) -> None:
        """Initialize the menus and menuitem event handlers."""
        menu_xrc_path = get_data_path(os.path.join("xrc", "mainmenu.xrc"))
        use_popup_menu = False
        if use_popup_menu:
            with open(menu_xrc_path, "rb") as xrc_file:
                xrc_xml = xrc_file.read().decode()
            xrc_xml = xrc_xml.replace('<object class="wxMenuBar" name="menu">', "")
            xrc_xml = xrc_xml.replace("</object>\n</resource>", "</resource>")
            res = xrc.XmlResource()
            res.LoadFromBuffer(xrc_xml)
            self.menubar = PopupMenu(self.header)
            for label in ("file", "options", "tools", "language", "help"):
                menu_label = f"menu.{label}"
                menu_name = "wxID_HELP" if label == "help" else menu_label
                menu = res.LoadMenu(menu_name)
                self.menubar.Append(menu, menu_label)
            self.header.Bind(wx.EVT_RIGHT_UP, lambda e: self.menubar.popup())
        else:
            res = xrc.XmlResource(menu_xrc_path)
            self.menubar = res.LoadMenuBar(self, "menu")
            if sys.platform == "darwin":
                # https://github.com/eoyilmaz/displaycal-py3/issues/303
                # set the menubar as the common menu bar,
                # otherwise it will be hidden when the measurement window is
                # shown
                wx.MenuBar.MacSetCommonMenuBar(self.menubar)

        file_ = self.menubar.GetMenu(self.menubar.FindMenu("menu.file"))
        menuitem = file_.FindItemById(file_.FindItem("calibration.load"))
        self.Bind(wx.EVT_MENU, self.load_cal_handler, menuitem)
        menuitem = file_.FindItemById(file_.FindItem("testchart.set"))
        self.Bind(wx.EVT_MENU, self.testchart_btn_handler, menuitem)
        self.menuitem_testchart_edit = file_.FindItemById(
            file_.FindItem("testchart.edit")
        )
        self.Bind(
            wx.EVT_MENU, self.create_testchart_btn_handler, self.menuitem_testchart_edit
        )
        menuitem = file_.FindItemById(file_.FindItem("profile.set_save_path"))
        self.Bind(wx.EVT_MENU, self.profile_save_path_btn_handler, menuitem)
        self.menuitem_profile_info = file_.FindItemById(file_.FindItem("profile.info"))
        self.Bind(wx.EVT_MENU, self.profile_info_handler, self.menuitem_profile_info)
        self.menuitem_create_profile = file_.FindItemById(
            file_.FindItem("create_profile")
        )
        self.Bind(
            wx.EVT_MENU, self.create_profile_handler, self.menuitem_create_profile
        )
        self.menuitem_create_profile_from_edid = file_.FindItemById(
            file_.FindItem("create_profile_from_edid")
        )
        self.Bind(
            wx.EVT_MENU,
            self.create_profile_from_edid,
            self.menuitem_create_profile_from_edid,
        )
        self.menuitem_install_display_profile = file_.FindItemById(
            file_.FindItem("install_display_profile")
        )
        self.Bind(
            wx.EVT_MENU,
            self.select_install_profile_handler,
            self.menuitem_install_display_profile,
        )
        self.menuitem_profile_share = file_.FindItemById(
            file_.FindItem("profile.share")
        )
        self.Bind(wx.EVT_MENU, self.profile_share_handler, self.menuitem_profile_share)
        if sys.platform != "darwin" or wx.VERSION >= (2, 9):
            file_.AppendSeparator()
        self.menuitem_prefs = file_.Append(
            (
                -1
                if wx.VERSION < (2, 9) or sys.platform != "darwin"
                else wx.ID_PREFERENCES
            ),
            "&" + "menuitem.set_argyll_bin",
        )
        self.Bind(wx.EVT_MENU, self.set_argyll_bin_handler, self.menuitem_prefs)
        if sys.platform != "darwin" or wx.VERSION >= (2, 9):
            file_.AppendSeparator()
        self.menuitem_quit = file_.Append(
            -1 if wx.VERSION < (2, 9) else wx.ID_EXIT, "&menuitem.quit\tCtrl+Q"
        )
        self.Bind(wx.EVT_MENU, self.OnClose, self.menuitem_quit)

        options = self.menubar.GetMenu(self.menubar.FindMenu("menu.options"))
        self.menuitem_advanced_options = options.FindItemById(
            options.FindItem("advanced")
        )
        options_advanced = self.menuitem_advanced_options.SubMenu
        self.menu_advanced_options = options_advanced
        self.menuitem_skip_legacy_serial_ports = options_advanced.FindItemById(
            options_advanced.FindItem("skip_legacy_serial_ports")
        )
        self.Bind(
            wx.EVT_MENU,
            self.skip_legacy_serial_ports_handler,
            self.menuitem_skip_legacy_serial_ports,
        )
        self.menuitem_use_separate_lut_access = options_advanced.FindItemById(
            options_advanced.FindItem("use_separate_lut_access")
        )
        if sys.platform not in ("darwin", "win32") or TEST:
            self.Bind(
                wx.EVT_MENU,
                self.use_separate_lut_access_handler,
                self.menuitem_use_separate_lut_access,
            )
        else:
            options_advanced.RemoveItem(self.menuitem_use_separate_lut_access)
        self.menuitem_do_not_use_video_lut = options_advanced.FindItemById(
            options_advanced.FindItem("calibration.do_not_use_video_lut")
        )
        self.Bind(
            wx.EVT_MENU,
            self.do_not_use_video_lut_handler,
            self.menuitem_do_not_use_video_lut,
        )
        self.menuitem_allow_skip_sensor_cal = options_advanced.FindItemById(
            options_advanced.FindItem("allow_skip_sensor_cal")
        )
        self.Bind(
            wx.EVT_MENU,
            self.allow_skip_sensor_cal_handler,
            self.menuitem_allow_skip_sensor_cal,
        )
        self.menuitem_show_advanced_options = options.FindItemById(
            options.FindItem("show_advanced_options")
        )
        self.Bind(
            wx.EVT_MENU,
            self.show_advanced_options_handler,
            self.menuitem_show_advanced_options,
        )
        self.menuitem_enable_3dlut_tab = options.FindItemById(
            options.FindItem("3dlut.tab.enable")
        )
        self.Bind(
            wx.EVT_MENU, self.enable_3dlut_tab_handler, self.menuitem_enable_3dlut_tab
        )
        menuitem = options_advanced.FindItemById(
            options_advanced.FindItem("extra_args")
        )
        self.Bind(wx.EVT_MENU, self.extra_args_handler, menuitem)
        self.menuitem_enable_argyll_debug = options_advanced.FindItemById(
            options_advanced.FindItem("enable_argyll_debug")
        )
        self.Bind(
            wx.EVT_MENU,
            self.enable_argyll_debug_handler,
            self.menuitem_enable_argyll_debug,
        )
        self.menuitem_enable_dry_run = options_advanced.FindItemById(
            options_advanced.FindItem("dry_run")
        )
        self.Bind(
            wx.EVT_MENU, self.enable_dry_run_handler, self.menuitem_enable_dry_run
        )
        self.menuitem_startup_sound = options.FindItemById(
            options.FindItem("startup_sound.enable")
        )
        self.Bind(
            wx.EVT_MENU, self.startup_sound_enable_handler, self.menuitem_startup_sound
        )
        self.menuitem_use_simple_splash = options.FindItemById(
            options.FindItem("splash.simple")
        )
        self.Bind(
            wx.EVT_MENU,
            self.use_simple_splash_handler,
            self.menuitem_use_simple_splash,
        )
        self.menuitem_use_fancy_progress = options.FindItemById(
            options.FindItem("use_fancy_progress")
        )
        self.Bind(
            wx.EVT_MENU,
            self.use_fancy_progress_handler,
            self.menuitem_use_fancy_progress,
        )
        menuitem = options.FindItemById(options.FindItem("restore_defaults"))
        self.Bind(wx.EVT_MENU, self.restore_defaults_handler, menuitem)

        tools = self.menubar.GetMenu(self.menubar.FindMenu("menu.tools"))
        tools_vcgt = tools.FindItemById(
            tools.FindItem("video_card_gamma_table")
        ).SubMenu
        tools_reports = tools.FindItemById(tools.FindItem("report")).SubMenu
        tools_advanced = tools.FindItemById(tools.FindItem("advanced")).SubMenu
        tools_instrument = tools.FindItemById(tools.FindItem("instrument")).SubMenu
        tools_ccxx = tools.FindItemById(
            tools.FindItem("colorimeter_correction_matrix_file")
        ).SubMenu

        self.menuitem_load_lut_from_cal_or_profile = tools_vcgt.FindItemById(
            tools_vcgt.FindItem("calibration.load_from_cal_or_profile")
        )
        self.Bind(
            wx.EVT_MENU,
            self.load_profile_cal_handler,
            self.menuitem_load_lut_from_cal_or_profile,
        )
        self.menuitem_load_lut_from_display_profile = tools_vcgt.FindItemById(
            tools_vcgt.FindItem("calibration.load_from_display_profile")
        )
        self.Bind(
            wx.EVT_MENU,
            self.load_display_profile_cal,
            self.menuitem_load_lut_from_display_profile,
        )
        self.menuitem_lut_reset = tools_vcgt.FindItemById(
            tools_vcgt.FindItem("calibration.reset")
        )
        self.Bind(wx.EVT_MENU, self.reset_cal, self.menuitem_lut_reset)

        self.menuitem_measurement_report = tools_reports.FindItemById(
            tools_reports.FindItem("measurement_report")
        )
        self.Bind(
            wx.EVT_MENU,
            self.measurement_report_handler,
            self.menuitem_measurement_report,
        )
        self.menuitem_report_uncalibrated = tools_reports.FindItemById(
            tools_reports.FindItem("report.uncalibrated")
        )
        self.Bind(
            wx.EVT_MENU,
            self.report_uncalibrated_handler,
            self.menuitem_report_uncalibrated,
        )
        self.menuitem_report_calibrated = tools_reports.FindItemById(
            tools_reports.FindItem("report.calibrated")
        )
        self.Bind(
            wx.EVT_MENU, self.report_calibrated_handler, self.menuitem_report_calibrated
        )
        self.menuitem_calibration_verify = tools_reports.FindItemById(
            tools_reports.FindItem("calibration.verify")
        )
        self.Bind(
            wx.EVT_MENU,
            self.verify_calibration_handler,
            self.menuitem_calibration_verify,
        )
        menuitem = tools_reports.FindItemById(
            tools_reports.FindItem("measurement_report.update")
        )
        self.Bind(wx.EVT_MENU, self.update_measurement_report, menuitem)
        self.menuitem_measure_uniformity = tools_reports.FindItemById(
            tools_reports.FindItem("report.uniformity")
        )
        self.Bind(
            wx.EVT_MENU,
            self.measure_uniformity_handler,
            self.menuitem_measure_uniformity,
        )

        self.menuitem_measure_testchart = tools_advanced.FindItemById(
            tools_advanced.FindItem("measure.testchart")
        )
        self.Bind(wx.EVT_MENU, self.measure_handler, self.menuitem_measure_testchart)
        self.menuitem_specplot_run = tools_advanced.FindItemById(
            tools_advanced.FindItem("specplot.run")
        )
        self.Bind(wx.EVT_MENU, self.specplot_handler, self.menuitem_specplot_run)

        self.menuitem_profile_hires_b2a = tools_advanced.FindItemById(
            tools_advanced.FindItem("profile.b2a.hires")
        )
        self.Bind(
            wx.EVT_MENU, self.profile_hires_b2a_handler, self.menuitem_profile_hires_b2a
        )

        self.menuitem_measurement_file_check = tools_advanced.FindItemById(
            tools_advanced.FindItem("measurement_file.check_sanity")
        )
        self.Bind(
            wx.EVT_MENU,
            self.measurement_file_check_handler,
            self.menuitem_measurement_file_check,
        )

        self.menuitem_measurement_file_check_auto = tools_advanced.FindItemById(
            tools_advanced.FindItem("measurement_file.check_sanity.auto")
        )
        self.Bind(
            wx.EVT_MENU,
            self.measurement_file_check_auto_handler,
            self.menuitem_measurement_file_check_auto,
        )

        self.menuitem_choose_colorimeter_correction = tools_ccxx.FindItemById(
            tools_ccxx.FindItem("colorimeter_correction_matrix_file.choose")
        )
        self.Bind(
            wx.EVT_MENU,
            self.colorimeter_correction_matrix_ctrl_handler,
            self.menuitem_choose_colorimeter_correction,
        )
        self.menuitem_colorimeter_correction_web = tools_ccxx.FindItemById(
            tools_ccxx.FindItem("colorimeter_correction.web_check")
        )
        self.Bind(
            wx.EVT_MENU,
            self.colorimeter_correction_web_handler,
            self.menuitem_colorimeter_correction_web,
        )
        self.menuitem_import_colorimeter_correction = tools_ccxx.FindItemById(
            tools_ccxx.FindItem("colorimeter_correction.import")
        )
        self.Bind(
            wx.EVT_MENU,
            self.import_colorimeter_corrections_handler,
            self.menuitem_import_colorimeter_correction,
        )
        self.menuitem_create_colorimeter_correction = tools_ccxx.FindItemById(
            tools_ccxx.FindItem("colorimeter_correction.create")
        )
        self.Bind(
            wx.EVT_MENU,
            self.create_colorimeter_correction_handler,
            self.menuitem_create_colorimeter_correction,
        )
        self.menuitem_upload_colorimeter_correction = tools_ccxx.FindItemById(
            tools_ccxx.FindItem("colorimeter_correction.upload")
        )
        self.Bind(
            wx.EVT_MENU,
            self.upload_colorimeter_correction_handler,
            self.menuitem_upload_colorimeter_correction,
        )

        self.menuitem_synthicc_create = tools_advanced.FindItemById(
            tools_advanced.FindItem("synthicc.create")
        )
        self.Bind(
            wx.EVT_MENU, self.synthicc_create_handler, self.menuitem_synthicc_create
        )

        self.menuitem_install_argyll_instrument_conf = tools_instrument.FindItemById(
            tools_instrument.FindItem("argyll.instrument.configuration_files.install")
        )
        self.menuitem_uninstall_argyll_instrument_conf = tools_instrument.FindItemById(
            tools_instrument.FindItem("argyll.instrument.configuration_files.uninstall")
        )
        if sys.platform in ("darwin", "win32") and not TEST:
            tools_instrument.RemoveItem(self.menuitem_install_argyll_instrument_conf)
            tools_instrument.RemoveItem(self.menuitem_uninstall_argyll_instrument_conf)
        else:
            # Linux may need instrument access being setup
            self.Bind(
                wx.EVT_MENU,
                self.install_argyll_instrument_conf,
                self.menuitem_install_argyll_instrument_conf,
            )
            self.Bind(
                wx.EVT_MENU,
                self.uninstall_argyll_instrument_conf,
                self.menuitem_uninstall_argyll_instrument_conf,
            )
        self.menuitem_install_argyll_instrument_drivers = tools_instrument.FindItemById(
            tools_instrument.FindItem("argyll.instrument.drivers.install")
        )
        self.menuitem_uninstall_argyll_instrument_drivers = (
            tools_instrument.FindItemById(
                tools_instrument.FindItem("argyll.instrument.drivers.uninstall")
            )
        )
        if sys.platform == "win32" or TEST:
            # Windows may need an Argyll CMS instrument driver
            self.Bind(
                wx.EVT_MENU,
                self.install_argyll_instrument_drivers,
                self.menuitem_install_argyll_instrument_drivers,
            )
        else:
            # Other OS do not need an Argyll CMS instrument driver
            tools_instrument.RemoveItem(self.menuitem_install_argyll_instrument_drivers)
        if (sys.platform == "win32" and sys.getwindowsversion() >= (6,)) or TEST:
            # Windows Vista and newer can uninstall Argyll CMS instrument driver
            self.Bind(
                wx.EVT_MENU,
                self.uninstall_argyll_instrument_drivers,
                self.menuitem_uninstall_argyll_instrument_drivers,
            )
        else:
            # Other OS cannot uninstall Argyll CMS instrument driver
            tools_instrument.RemoveItem(
                self.menuitem_uninstall_argyll_instrument_drivers
            )
        self.menuitem_enable_spyder2 = tools_instrument.FindItemById(
            tools_instrument.FindItem("enable_spyder2")
        )
        self.Bind(
            wx.EVT_MENU, self.enable_spyder2_handler, self.menuitem_enable_spyder2
        )
        self.menuitem_calibrate_instrument = tools_instrument.FindItemById(
            tools_instrument.FindItem("calibrate_instrument")
        )
        self.Bind(
            wx.EVT_MENU,
            self.calibrate_instrument_handler,
            self.menuitem_calibrate_instrument,
        )
        menuitem = tools.FindItemById(tools.FindItem("detect_displays_and_ports"))
        self.Bind(wx.EVT_MENU, self.check_update_controls, menuitem)
        self.menuitem_show_lut = tools.FindItemById(
            tools.FindItem("calibration.show_lut")
        )
        self.Bind(wx.EVT_MENU, self.init_lut_viewer, self.menuitem_show_lut)
        self.menuitem_show_log = tools.FindItemById(tools.FindItem("infoframe.toggle"))
        self.Bind(wx.EVT_MENU, self.infoframe_toggle_handler, self.menuitem_show_log)
        self.menuitem_log_autoshow = tools.FindItemById(tools.FindItem("log.autoshow"))
        self.Bind(
            wx.EVT_MENU, self.infoframe_autoshow_handler, self.menuitem_log_autoshow
        )

        languages = self.menubar.GetMenu(self.menubar.FindMenu("menu.language"))
        llist = [
            (lang.LDICT[lcode].get("!language", ""), lcode) for lcode in lang.LDICT
        ]
        llist.sort()
        # Map language code to ISO 3166-1 alpha-2 country code
        lmap = {"en": "us", "ko": "kr", "ukr": "ua", "zh_hk": "cn", "zh_cn": "cn"}
        for lstr, lcode in llist:
            menuitem = languages.Append(-1, "&" + lstr, kind=wx.ITEM_RADIO)
            if (lcode2 := lmap.get(lcode, lcode).upper()) in flagart.catalog and (
                sys.platform in ("darwin", "win32")
                or menuitem.GetKind() == wx.ITEM_NORMAL
            ):
                # This can fail under Linux with wxPython 3.0
                # because only normal menu items can have bitmaps
                # there. Working fine on all other platforms.
                pyimg = flagart.catalog[lcode2]
                if pyimg.Image.IsOk():
                    bmp = pyimg.GetBitmap()
                    if bmp.IsOk():
                        menuitem.SetBitmap(bmp)
            if lang.getcode() == lcode:
                menuitem.Check()
                font = menuitem.Font
                font.SetWeight(wx.FONTWEIGHT_BOLD)
                menuitem.SetFont(font)
            # Map numerical event id to language string
            lang.LDICT[lcode].menuitem_id = menuitem.GetId()
            self.Bind(wx.EVT_MENU, self.set_language_handler, menuitem)

        help_action = self.menubar.GetMenu(self.menubar.FindMenu("menu.help"))
        self.menuitem_about = help_action.Append(
            -1 if wx.VERSION < (2, 9) else wx.ID_ABOUT, "&menu.about"
        )
        self.Bind(wx.EVT_MENU, self.aboutdialog_handler, self.menuitem_about)
        self.menuitem_readme = help_action.FindItemById(help_action.FindItem("readme"))
        self.menuitem_readme.Enable(isinstance(get_data_path("README.html"), str))
        self.Bind(wx.EVT_MENU, self.readme_handler, self.menuitem_readme)
        self.menuitem_license = help_action.FindItemById(
            help_action.FindItem("license")
        )
        self.menuitem_license.Enable(
            isinstance(get_data_path("LICENSE.txt"), str)
            or os.path.isfile("/usr/share/common-licenses/GPL-3")
        )
        self.Bind(wx.EVT_MENU, self.license_handler, self.menuitem_license)
        menuitem = help_action.FindItemById(help_action.FindItem("go_to_website"))
        self.Bind(
            wx.EVT_MENU, lambda event: launch_file(f"https://{DOMAIN}/"), menuitem
        )
        menuitem = help_action.FindItemById(help_action.FindItem("help_support"))
        self.Bind(wx.EVT_MENU, self.help_support_handler, menuitem)
        menuitem = help_action.FindItemById(help_action.FindItem("bug_report"))
        self.Bind(wx.EVT_MENU, self.bug_report_handler, menuitem)
        self.menuitem_app_auto_update_check = help_action.FindItemById(
            help_action.FindItem("update_check.onstartup")
        )
        self.Bind(
            wx.EVT_MENU,
            self.app_auto_update_check_handler,
            self.menuitem_app_auto_update_check,
        )
        menuitem = help_action.FindItemById(help_action.FindItem("update_check"))
        self.Bind(wx.EVT_MENU, self.app_update_check_handler, menuitem)

        if sys.platform == "darwin":
            wx.GetApp().SetMacAboutMenuItemId(self.menuitem_about.GetId())
            wx.GetApp().SetMacPreferencesMenuItemId(self.menuitem_prefs.GetId())
            wx.GetApp().SetMacExitMenuItemId(self.menuitem_quit.GetId())
            wx.GetApp().SetMacHelpMenuTitleName(lang.getstr("menu.help"))
        if use_popup_menu:
            self.menubar.bind_keys()

    def update_menus(self) -> None:
        """Enable/disable menu items based on available Argyll functionality."""
        self.menuitem_testchart_edit.Enable(self.create_testchart_btn.Enabled)
        self.menuitem_measure_testchart.Enable(
            bool(self.worker.displays) and bool(self.worker.instruments)
        )
        self.menuitem_specplot_run.Enable(self.worker.argyll_version > [0, 0, 0])
        self.menuitem_create_profile.Enable(bool(self.worker.displays))
        edid = self.worker.get_display_edid()
        self.menuitem_create_profile_from_edid.Enable(
            bool(
                self.worker.displays
                and edid
                and edid.get("monitor_name", edid.get("ascii", edid["product_id"]))
                and edid["red_x"]
                and edid["red_y"]
                and edid["green_x"]
                and edid["green_y"]
                and edid["blue_x"]
                and edid["blue_y"]
            )
        )
        self.menuitem_profile_hires_b2a.Enable(self.worker.argyll_version > [0, 0, 0])
        self.menuitem_install_display_profile.Enable(
            bool(self.worker.displays) and not config.is_virtual_display()
        )
        calibration_loading_supported = self.worker.calibration_loading_supported
        self.menuitem_load_lut_from_cal_or_profile.Enable(
            bool(self.worker.displays) and calibration_loading_supported
        )
        self.menuitem_load_lut_from_display_profile.Enable(
            bool(self.worker.displays) and calibration_loading_supported
        )
        self.menuitem_skip_legacy_serial_ports.Check(
            bool(getcfg("skip_legacy_serial_ports"))
        )
        if sys.platform not in ("darwin", "win32") or TEST:
            has_separate_lut_access = self.worker.has_separate_lut_access()
            self.menuitem_use_separate_lut_access.Check(
                has_separate_lut_access or bool(getcfg("use_separate_lut_access"))
            )
            self.menuitem_use_separate_lut_access.Enable(not has_separate_lut_access)
        has_lut_access = self.worker.has_lut_access()
        do_not_use_video_lut = self.worker.argyll_version >= [1, 3, 3] and (
            not has_lut_access or not getcfg("calibration.use_video_lut")
        )
        self.menuitem_do_not_use_video_lut.Check(do_not_use_video_lut)
        self.menuitem_do_not_use_video_lut.Enable(
            self.worker.argyll_version >= [1, 3, 3] and has_lut_access
        )
        self.menuitem_allow_skip_sensor_cal.Check(bool(getcfg("allow_skip_sensor_cal")))
        self.menuitem_calibrate_instrument.Enable(
            bool(self.worker.get_instrument_features().get("sensor_cal"))
        )
        self.menuitem_enable_3dlut_tab.Check(bool(getcfg("3dlut.tab.enable")))
        self.menuitem_enable_argyll_debug.Check(bool(getcfg("argyll.debug")))
        self.menuitem_enable_dry_run.Check(bool(getcfg("dry_run")))
        self.menuitem_startup_sound.Check(bool(getcfg("startup_sound.enable")))
        self.menuitem_use_simple_splash.Check(bool(getcfg("splash.simple")))
        self.menuitem_use_fancy_progress.Check(bool(getcfg("use_fancy_progress")))
        self.menuitem_advanced_options.Enable(bool(getcfg("show_advanced_options")))
        spyd2en = get_argyll_util("spyd2en")
        spyder2_firmware_exists = self.worker.spyder2_firmware_exists()
        if sys.platform not in ("darwin", "win32") or TEST:
            installed = get_argyll_instrument_config("installed")
            installable = get_argyll_instrument_config()
            # Only enable if not yet installed and installable
            self.menuitem_install_argyll_instrument_conf.Enable(
                bool(not installed and installable)
            )
            # Only enable if installed and (re-)installable
            self.menuitem_uninstall_argyll_instrument_conf.Enable(
                bool(installed and installable)
            )
        self.menuitem_enable_spyder2.Enable(bool(spyd2en))
        self.menuitem_enable_spyder2.Check(bool(spyd2en) and spyder2_firmware_exists)
        self.menuitem_show_lut.Enable(
            bool(LUTFrame) and self.worker.argyll_version > [0, 0, 0]
        )
        self.menuitem_show_lut.Check(bool(getcfg("lut_viewer.show")))
        if hasattr(self, "lut_viewer"):
            self.lut_viewer.update_controls()
        self.menuitem_lut_reset.Enable(
            bool(self.worker.displays) and calibration_loading_supported
        )
        mr_enable = (
            bool(self.worker.displays)
            and bool(self.worker.instruments)
            and getcfg("calibration.file", False) not in self.presets[1:]
        )
        self.menuitem_measurement_report.Enable(mr_enable)
        self.menuitem_report_calibrated.Enable(
            bool(self.worker.displays)
            and bool(self.worker.instruments)
            and not config.is_non_argyll_display()
        )
        self.menuitem_report_uncalibrated.Enable(
            bool(self.worker.displays)
            and bool(self.worker.instruments)
            and not config.is_non_argyll_display()
        )
        self.menuitem_calibration_verify.Enable(
            bool(self.worker.displays)
            and bool(self.worker.instruments)
            and not config.is_non_argyll_display()
        )
        self.mr_settings_btn.Enable(
            bool(self.worker.displays) and bool(self.worker.instruments)
        )
        self.menuitem_measure_uniformity.Enable(
            bool(self.worker.displays) and bool(self.worker.instruments)
        )
        self.menuitem_measurement_file_check_auto.Check(
            bool(getcfg("ti3.check_sanity.auto"))
        )
        self.menuitem_create_colorimeter_correction.Enable(
            bool(get_argyll_util("ccxxmake"))
        )
        self.menuitem_show_log.Check(bool(getcfg("log.show")))
        self.menuitem_log_autoshow.Enable(not bool(getcfg("log.show")))
        self.menuitem_log_autoshow.Check(bool(getcfg("log.autoshow")))
        self.menuitem_app_auto_update_check.Check(bool(getcfg("update_check")))

    def init_controls(self) -> None:
        """Initialize the main window controls and their event handlers."""
        for child in (
            self.display_box_label,
            self.instrument_box_label,
            self.calibration_settings_label,
            self.profile_settings_label,
            self.lut3d_settings_label,
            self.mr_settings_label,
        ):
            font = child.Font
            font.SetWeight(wx.FONTWEIGHT_BOLD)
            child.Font = font

        # Settings file controls
        # ======================

        # Settings file dropdown
        self.Bind(
            wx.EVT_CHOICE,
            self.calibration_file_ctrl_handler,
            id=self.calibration_file_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON, self.load_cal_handler, id=self.calibration_file_btn.GetId()
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.create_session_archive_handler,
            id=self.create_session_archive_btn.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.delete_calibration_handler,
            id=self.delete_calibration_btn.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.install_profile_handler,
            id=self.install_profile_btn.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON, self.profile_info_handler, id=self.profile_info_btn.GetId()
        )

        # Update calibration checkbox
        self.Bind(
            wx.EVT_CHECKBOX,
            self.calibration_update_ctrl_handler,
            id=self.calibration_update_cb.GetId(),
        )

        # Display
        self.Bind(
            wx.EVT_CHOICE, self.display_ctrl_handler, id=self.display_ctrl.GetId()
        )
        self.Bind(
            wx.EVT_CHOICE,
            self.display_lut_ctrl_handler,
            id=self.display_lut_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.display_lut_link_ctrl_handler,
            id=self.display_lut_link_ctrl.GetId(),
        )

        # Instrument
        self.Bind(
            wx.EVT_CHOICE, self.comport_ctrl_handler, id=self.comport_ctrl.GetId()
        )
        self.Bind(
            wx.EVT_CHOICE,
            self.measurement_mode_ctrl_handler,
            id=self.measurement_mode_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.check_update_controls,
            id=self.detect_displays_and_ports_btn.GetId(),
        )

        # Display update delay & settle time
        min_val, max_val = config.VALID_RANGES["measure.min_display_update_delay_ms"]
        self.min_display_update_delay_ms.SetRange(min_val, max_val)

        min_val, max_val = config.VALID_RANGES["measure.display_settle_time_mult"]
        self.display_settle_time_mult.SetDigits(
            len(str(stripzeros(min_val)).split(".")[-1])
        )
        self.display_settle_time_mult.SetIncrement(min_val)
        self.display_settle_time_mult.SetRange(min_val, max_val)
        self.Bind(
            wx.EVT_CHECKBOX,
            self.display_delay_handler,
            id=self.override_min_display_update_delay_ms.GetId(),
        )
        self.Bind(
            wx.EVT_TEXT,
            self.display_delay_handler,
            id=self.min_display_update_delay_ms.GetId(),
        )
        self.Bind(
            wx.EVT_CHECKBOX,
            self.display_delay_handler,
            id=self.override_display_settle_time_mult.GetId(),
        )
        self.Bind(
            floatspin.EVT_FLOATSPIN,
            self.display_delay_handler,
            id=self.display_settle_time_mult.GetId(),
        )

        # frame insertion
        self.ffp_insertion.Bind(
            wx.EVT_CHECKBOX,
            lambda event: (
                setcfg("patterngenerator.ffp_insertion", event.GetInt())
                or self.update_ffp_insertion_ctrl()
                or self.update_estimated_measurement_times()
            ),
        )
        min_val, max_val = config.VALID_RANGES[
            "patterngenerator.ffp_insertion.interval"
        ]
        self.ffp_insertion_interval.SetRange(min_val, max_val)
        self.ffp_insertion_interval.Bind(
            floatspin.EVT_FLOATSPIN,
            lambda event: (
                setcfg("patterngenerator.ffp_insertion.interval", event.GetValue())
                or self.update_estimated_measurement_times()
            ),
        )
        min_val, max_val = config.VALID_RANGES[
            "patterngenerator.ffp_insertion.duration"
        ]
        self.ffp_insertion_duration.SetRange(min_val, max_val)
        self.ffp_insertion_duration.Bind(
            floatspin.EVT_FLOATSPIN,
            lambda event: (
                setcfg("patterngenerator.ffp_insertion.duration", event.GetValue())
                or self.update_estimated_measurement_times()
            ),
        )
        self.ffp_insertion_level.Bind(
            wx.EVT_SPINCTRL,
            lambda event: setcfg(
                "patterngenerator.ffp_insertion.level", event.GetPosition() / 100.0
            ),
        )

        # Output levels
        self.output_levels_auto.Bind(wx.EVT_RADIOBUTTON, self.output_levels_handler)
        self.output_levels_full_range.Bind(
            wx.EVT_RADIOBUTTON, self.output_levels_handler
        )
        self.output_levels_limited_range.Bind(
            wx.EVT_RADIOBUTTON, self.output_levels_handler
        )

        # Observer
        self.observer_ctrl.Bind(wx.EVT_CHOICE, self.observer_ctrl_handler)

        # Colorimeter correction matrix
        self.Bind(
            wx.EVT_CHOICE,
            self.colorimeter_correction_matrix_ctrl_handler,
            id=self.colorimeter_correction_matrix_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.colorimeter_correction_matrix_ctrl_handler,
            id=self.colorimeter_correction_matrix_btn.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.colorimeter_correction_info_handler,
            id=self.colorimeter_correction_info_btn.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.colorimeter_correction_web_handler,
            id=self.colorimeter_correction_web_btn.GetId(),
        )
        self.colorimeter_correction_create_btn.Bind(
            wx.EVT_BUTTON, self.create_colorimeter_correction_handler
        )

        # Display tech info
        self.Bind(
            wx.EVT_BUTTON,
            self.display_tech_info_show_handler,
            id=self.display_tech_info_show_btn.Id,
        )

        # Calibration settings
        # ====================

        # Whitepoint
        self.Bind(
            wx.EVT_CHOICE, self.whitepoint_ctrl_handler, id=self.whitepoint_ctrl.GetId()
        )
        self.Bind(
            wx.EVT_COMBOBOX,
            self.whitepoint_ctrl_handler,
            id=self.whitepoint_colortemp_textctrl.GetId(),
        )
        self.whitepoint_colortemp_textctrl.SetItems(self.whitepoint_presets)
        self.whitepoint_colortemp_textctrl.Bind(
            wx.EVT_KILL_FOCUS, self.whitepoint_ctrl_handler
        )
        self.Bind(
            wx.EVT_CHOICE,
            self.whitepoint_colortemp_locus_ctrl_handler,
            id=self.whitepoint_colortemp_locus_ctrl.GetId(),
        )
        self.whitepoint_x_textctrl.Bind(
            floatspin.EVT_FLOATSPIN, self.whitepoint_ctrl_handler
        )
        self.whitepoint_y_textctrl.Bind(
            floatspin.EVT_FLOATSPIN, self.whitepoint_ctrl_handler
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.ambient_measure_handler,
            id=self.whitepoint_measure_btn.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.visual_whitepoint_editor_handler,
            id=self.visual_whitepoint_editor_btn.GetId(),
        )

        # White luminance
        self.Bind(
            wx.EVT_CHOICE, self.luminance_ctrl_handler, id=self.luminance_ctrl.GetId()
        )
        self.luminance_textctrl.Bind(
            floatspin.EVT_FLOATSPIN, self.luminance_ctrl_handler
        )
        self.Bind(
            wx.EVT_CHECKBOX,
            self.whitelevel_drift_compensation_handler,
            id=self.whitelevel_drift_compensation.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.luminance_measure_handler,
            id=self.luminance_measure_btn.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.ambient_measure_handler,
            id=self.ambient_luminance_measure_btn.GetId(),
        )

        # Black luminance
        self.Bind(
            wx.EVT_CHOICE,
            self.black_luminance_ctrl_handler,
            id=self.black_luminance_ctrl.GetId(),
        )
        self.black_luminance_textctrl.Bind(
            floatspin.EVT_FLOATSPIN, self.black_luminance_ctrl_handler
        )
        self.Bind(
            wx.EVT_CHECKBOX,
            self.blacklevel_drift_compensation_handler,
            id=self.blacklevel_drift_compensation.GetId(),
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.luminance_measure_handler,
            id=self.black_luminance_measure_btn.GetId(),
        )

        # Tonal response curve (TRC)
        self.Bind(wx.EVT_CHOICE, self.trc_ctrl_handler, id=self.trc_ctrl.GetId())
        self.trc_textctrl.SetItems(self.trc_presets)
        self.trc_textctrl.SetValue(str(DEFAULTS["gamma"]))
        self.Bind(wx.EVT_COMBOBOX, self.trc_ctrl_handler, id=self.trc_textctrl.GetId())
        self.trc_textctrl.Bind(wx.EVT_KILL_FOCUS, self.trc_ctrl_handler)
        self.Bind(
            wx.EVT_CHOICE, self.trc_type_ctrl_handler, id=self.trc_type_ctrl.GetId()
        )

        # Viewing condition adjustment for ambient in Lux
        self.Bind(
            wx.EVT_CHECKBOX,
            self.ambient_viewcond_adjust_ctrl_handler,
            id=self.ambient_viewcond_adjust_cb.GetId(),
        )
        self.ambient_viewcond_adjust_textctrl.Bind(
            floatspin.EVT_FLOATSPIN, self.ambient_viewcond_adjust_ctrl_handler
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.ambient_measure_handler,
            id=self.ambient_measure_btn.GetId(),
        )

        # Black level output offset
        self.Bind(
            wx.EVT_SLIDER,
            self.black_output_offset_ctrl_handler,
            id=self.black_output_offset_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_TEXT,
            self.black_output_offset_ctrl_handler,
            id=self.black_output_offset_intctrl.GetId(),
        )

        # Black point hue correction
        self.Bind(
            wx.EVT_CHECKBOX,
            self.black_point_correction_auto_handler,
            id=self.black_point_correction_auto_cb.GetId(),
        )
        self.Bind(
            wx.EVT_SLIDER,
            self.black_point_correction_ctrl_handler,
            id=self.black_point_correction_ctrl.GetId(),
        )
        self.Bind(
            wx.EVT_TEXT,
            self.black_point_correction_ctrl_handler,
            id=self.black_point_correction_intctrl.GetId(),
        )

        # Black point correction rate
        self.Bind(
            wx.EVT_SLIDER,
            self.black_point_rate_ctrl_handler,
            id=self.black_point_rate_ctrl.GetId(),
        )
        self.Bind(
            floatspin.EVT_FLOATSPIN,
            self.black_point_rate_ctrl_handler,
            id=self.black_point_rate_floatctrl.GetId(),
        )

        # Calibration quality
        self.Bind(
            wx.EVT_SLIDER,
            self.calibration_quality_ctrl_handler,
            id=self.calibration_quality_ctrl.GetId(),
        )

        # Interactive display adjustment
        self.Bind(
            wx.EVT_CHECKBOX,
            self.interactive_display_adjustment_ctrl_handler,
            id=self.interactive_display_adjustment_cb.GetId(),
        )

        # Profiling settings
        # ==================

        # Testchart file
        self.Bind(
            wx.EVT_CHOICE, self.testchart_ctrl_handler, id=self.testchart_ctrl.GetId()
        )
        self.Bind(
            wx.EVT_BUTTON, self.testchart_btn_handler, id=self.testchart_btn.GetId()
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.create_testchart_btn_handler,
            id=self.create_testchart_btn.GetId(),
        )
        self.testchart_patches_amount_ctrl.SetRange(
            config.VALID_VALUES["testchart.auto_optimize"][1],
            config.VALID_VALUES["testchart.auto_optimize"][-1],
        )
        self.testchart_patches_amount_ctrl.Bind(
            wx.EVT_SLIDER, self.testchart_patches_amount_ctrl_handler
        )

        # Patch sequence
        self.Bind(
            wx.EVT_CHOICE,
            self.testchart_patch_sequence_ctrl_handler,
            id=self.testchart_patch_sequence_ctrl.GetId(),
        )

        # Profile quality
        self.Bind(
            wx.EVT_SLIDER,
            self.profile_quality_ctrl_handler,
            id=self.profile_quality_ctrl.GetId(),
        )

        # Profile type
        self.Bind(
            wx.EVT_CHOICE,
            self.profile_type_ctrl_handler,
            id=self.profile_type_ctrl.GetId(),
        )

        # Advanced (gamut mapping)
        self.Bind(wx.EVT_BUTTON, self.gamap_btn_handler, id=self.gamap_btn.GetId())

        # Black point compensation
        self.Bind(
            wx.EVT_CHECKBOX,
            self.black_point_compensation_ctrl_handler,
            id=self.black_point_compensation_cb.GetId(),
        )

        # Profile name
        self.Bind(
            wx.EVT_TEXT,
            self.profile_name_ctrl_handler,
            id=self.profile_name_textctrl.GetId(),
        )
        self.profile_name_info_btn.Bind(
            wx.EVT_BUTTON, self.profile_name_info_btn_handler
        )
        self.profile_name_info_btn.SetToolTipString(lang.getstr("profile.name"))
        self.Bind(
            wx.EVT_BUTTON,
            self.profile_save_path_btn_handler,
            id=self.profile_save_path_btn.GetId(),
        )

        # 3D LUT controls
        # ===============

        self.lut3d_create_cb.Bind(wx.EVT_CHECKBOX, self.lut3d_create_cb_handler)
        self.lut3d_init_input_profiles()
        self.lut3d_input_profile_ctrl.Bind(
            wx.EVT_CHOICE, self.lut3d_input_colorspace_handler
        )
        self.lut3d_bind_event_handlers()

        # Main buttons
        # ============

        for btn_name in (
            "calibrate_btn",
            "calibrate_and_profile_btn",
            "profile_btn",
            "lut3d_create_btn",
            "measurement_report_btn",
        ):
            btn = getattr(self, btn_name)
            # wx.Button does not look correct when a custom background color is
            # set because the button label background inherits the button
            # background. Replace with ThemedGenButton which does not have
            # that issue
            subst = BorderGradientButton(
                parent=btn.Parent,
                bitmap=get_icon(16, "start"),
                label=btn.Label,
                name=btn.Name,
            )
            subst.SetBackgroundColour(btn.Parent.BackgroundColour)
            if sys.platform == "win32":
                subst.SetTopStartColour(
                    wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DLIGHT)
                )
                subst.SetTopEndColour(
                    wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DLIGHT)
                )  # Not used
                subst.SetBottomStartColour(
                    wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DLIGHT)
                )  # Not used
                subst.SetBottomEndColour(
                    wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DLIGHT)
                )
            else:
                subst.SetTopStartColour(
                    wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOW)
                )
                subst.SetBottomEndColour(
                    wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOW)
                )
            subst.SetForegroundColour(
                wx.SystemSettings.GetColour(wx.SYS_COLOUR_BTNTEXT)
            )
            subst.SetPressedTopColour(
                wx.SystemSettings.GetColour(wx.SYS_COLOUR_HIGHLIGHT)
            )
            subst.SetPressedBottomColour(
                wx.SystemSettings.GetColour(wx.SYS_COLOUR_HIGHLIGHT)
            )
            setattr(self, btn_name, subst)
            btn.ContainingSizer.Replace(btn, subst)
            btn.Destroy()

        self.Bind(
            wx.EVT_BUTTON, self.calibrate_btn_handler, id=self.calibrate_btn.GetId()
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.calibrate_and_profile_btn_handler,
            id=self.calibrate_and_profile_btn.GetId(),
        )
        self.Bind(wx.EVT_BUTTON, self.profile_btn_handler, id=self.profile_btn.GetId())
        self.lut3d_create_btn.Bind(wx.EVT_BUTTON, self.lut3d_create_handler)
        self.measurement_report_btn.Bind(wx.EVT_BUTTON, self.measurement_report_handler)

    def set_language_handler(self, event: wx.Event) -> None:
        """Set a new language globally and on-the-fly.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        for lcode in lang.LDICT:
            if lang.LDICT[lcode].menuitem_id != event.GetId():
                continue
            # Get the previously marked menu item
            menuitem = self.menubar.FindItemById(lang.LDICT[lang.getcode()].menuitem_id)
            if hasattr(self, "tcframe"):
                if not self.tcframe.tc_close_handler():
                    # Do not change language, mark previous menu item
                    menuitem.Check()
                    return
                self.tcframe.Destroy()
                del self.tcframe
            # Set the previously marked menu item's font weight to normal
            font = menuitem.Font
            font.SetWeight(wx.FONTWEIGHT_NORMAL)
            menuitem.SetFont(font)
            # Set the currently marked menu item's font weight to bold
            menuitem = self.menubar.FindItemById(lang.LDICT[lcode].menuitem_id)
            font = menuitem.Font
            font.SetWeight(wx.FONTWEIGHT_BOLD)
            menuitem.SetFont(font)
            setcfg("lang", lcode)
            writecfg()
            self.panel.Freeze()
            self.header.SetLabel(lang.getstr("header"))
            self.setup_language()
            if hasattr(self, "extra_args"):
                self.extra_args.Sizer.SetSizeHints(self.extra_args)
                self.extra_args.Sizer.Layout()
            if hasattr(self, "gamapframe"):
                self.gamapframe.panel.Freeze()
                self.gamapframe.setup_language()
                self.gamapframe.update_layout()
                self.gamapframe.panel.Thaw()
            if getattr(self, "lut3dframe", None):
                self.lut3dframe.panel.Freeze()
                self.lut3dframe.setup_language()
                self.lut3dframe.update_controls()
                self.lut3dframe.update_layout()
                self.lut3dframe.panel.Thaw()
            if getattr(self, "reportframe", None):
                self.reportframe.panel.Freeze()
                self.reportframe.setup_language()
                self.reportframe.update_controls()
                self.reportframe.update_layout()
                self.reportframe.panel.Thaw()
            if getattr(self, "synthiccframe", None):
                self.synthiccframe.panel.Freeze()
                self.synthiccframe.setup_language()
                self.synthiccframe.update_controls()
                self.synthiccframe.update_layout()
                self.synthiccframe.panel.Thaw()
            self.update_measurement_modes()
            self.update_controls()
            self.update_displays()
            self.set_testcharts()
            self.update_layout()
            self.panel.Thaw()
            if hasattr(self, "aboutdialog"):
                self.aboutdialog.Destroy()
                del self.aboutdialog
            log_txt = self.infoframe.log_txt.GetValue().encode("UTF-8", "replace")
            if log_txt:
                # Remember current log window contents
                if not self.infoframe.IsShownOnScreen():
                    # Append buffer of non-shown log window
                    LOGBUFFER.seek(0)
                    log_txt += LOGBUFFER.read()
                LOGBUFFER.truncate(0)
                LOGBUFFER.write(log_txt)
            self.infoframe.Destroy()
            self.init_infoframe(show=getcfg("log.show"))
            if sys.platform in ("darwin", "win32") or ISEXE:
                self.measureframe.Destroy()
                self.init_measureframe()
            if hasattr(self, "lut_viewer"):
                self.lut_viewer.Destroy()
                del self.lut_viewer
                if getcfg("lut_viewer.show"):
                    # Using wx.CallAfter fixes wrong positioning under wxGTK
                    # with wxPython 3
                    wx.CallAfter(self.init_lut_viewer, show=True)
            if hasattr(self, "profile_name_tooltip_window"):
                self.profile_name_tooltip_window.Destroy()
                del self.profile_name_tooltip_window
            if hasattr(self, "display_tech_info_tooltip_window"):
                self.display_tech_info_tooltip_window.Destroy()
                del self.display_tech_info_tooltip_window
            for progress_wnd in self.worker.progress_wnds:
                if progress_wnd:
                    progress_wnd.Destroy()
            wx.CallAfter(self.Raise)
            if isinstance(getattr(sys, "_appsocket", None), socket.socket):
                threading.Thread(
                    target=self.set_remote_language,
                    name="Scripting.SetClientLanguage",
                ).start()
            break

    def set_remote_language(self) -> None:
        """Set the language of all running standalone tools (if supported)."""
        # Set language of all running standalone tools (if supported)
        app_ip, app_port = sys._appsocket.getsockname()
        for host in self.get_scripting_hosts():
            ip_port, name = host.split(None, 1)
            ip, port = ip_port.split(":", 1)
            port = int(port)
            if ip == app_ip and port == app_port:
                continue
            try:
                conn = self.connect(ip, port)
                if isinstance(conn, Exception):
                    print("Warning - couldn't connect to", ip_port, f"({name}):", conn)
                    continue
                conn.send_command("getappname")
                remote_appname = conn.get_single_response()
                if remote_appname == APPNAME:
                    print("Warning - connected to self, skipping")
                    del conn
                    continue
                conn.send_command(f"setlanguage {lang.getcode()}")
                response = conn.get_single_response()
                if response not in ("ok", "invalid"):
                    print(
                        "Warning - couldn't set language for",
                        name,
                        f"({ip_port}):",
                        response,
                    )
                if remote_appname == f"{APPNAME}-apply-profiles":
                    # Update notification text of profile loader
                    conn.send_command(
                        "notify '{}' silent sticky".format(
                            lang.getstr(
                                "app.detected.calibration_loading_disabled", APPNAME
                            )
                        )
                    )
                    response = conn.get_single_response()
                    if response != "ok":
                        print(
                            "Warning - couldn't update profile loader "
                            "notification text:",
                            response,
                        )
                del conn
            except Exception as exception:
                print(
                    "Warning - error while trying to set language for",
                    name,
                    f"({ip_port})",
                    exception,
                )

    def update_layout(self) -> None:
        """Update main window layout."""
        self.set_size(True, True)

    def restore_defaults_handler(
        self,
        event: None | wx.Event = None,
        include: tuple = (),
        exclude: tuple = (),
        override: None | dict = None,
    ) -> None:
        """Restore default settings.

        Args:
            event (wx.Event): The event that triggered this handler, if any.
            include (tuple): Settings to include in the restore operation.
            exclude (tuple): Settings to exclude from the restore operation.
            override (dict): Settings to override with specific values.
        """
        if event:
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("app.confirm_restore_defaults"),
                ok=lang.getstr("ok"),
                cancel=lang.getstr("cancel"),
                bitmap=get_bitmap("theme/icons/32x32/dialog-warning"),
            )
            result = dlg.ShowModal()
            dlg.Destroy()
            if result != wx.ID_OK:
                return
        if getcfg("settings.changed"):
            self.settings_discard_changes()
        skip = [
            "allow_skip_sensor_cal",
            "app.allow_network_clients",
            "app.port",
            "argyll.dir",
            "argyll.version",
            "calibration.autoload",
            "calibration.black_point_rate.enabled",
            "calibration.file.previous",
            "calibration.update",
            "colorimeter_correction.instrument",
            "colorimeter_correction.instrument.reference",
            "colorimeter_correction.measurement_mode",
            "colorimeter_correction.measurement_mode.reference",
            "colorimeter_correction.measurement_mode.reference.projector",
            "colorimeter_correction_matrix_file",
            "comport.number",
            "copyright",
            "dimensions.measureframe.whitepoint.visual_editor",
            "display.number",
            "display.technology",
            "display_lut.link",
            "display_lut.number",
            "displays",
            "dry_run",
            "enumerate_ports.auto",
            "gamma",
            "iccgamut.surface_detail",
            "instruments",
            "lang",
            "last_3dlut_path",
            "last_cal_path",
            "last_cal_or_icc_path",
            "last_colorimeter_ti3_path",
            "last_filedialog_path",
            "last_icc_path",
            "last_reference_ti3_path",
            "last_testchart_export_path",
            "last_ti1_path",
            "last_ti3_path",
            "last_vrml_path",
            "log.show",
            "lut_viewer.show",
            "lut_viewer.show_actual_lut",
            "measurement_mode",
            "measurement_mode.projector",
            "measurement.name.expanded",
            "measurement.play_sound",
            "measurement.save_path",
            "multiprocessing.max_cpus",
            "patterngenerator.apl",
            "patterngenerator.resolve",
            "patterngenerator.resolve.port",
            "profile.b2a.hires.diagpng",
            "profile.create_gamut_views",
            "profile.install_scope",
            "profile.license",
            "profile.load_on_login",
            "profile.name",
            "profile.name.expanded",
            "profile.save_path",
            "profile_loader.check_gamma_ramps",
            "profile_loader.error.show_msg",
            "profile_loader.exceptions",
            "profile_loader.fix_profile_associations",
            "profile_loader.known_apps",
            "profile_loader.known_window_classes",
            "profile_loader.reset_gamma_ramps",
            "profile_loader.use_madhcnet",
            "profile_loader.verify_calibration",
            "profile.update",
            "position.x",
            "position.y",
            "position.info.x",
            "position.info.y",
            "position.lut_viewer.x",
            "position.lut_viewer.y",
            "position.lut3dframe.x",
            "position.lut3dframe.y",
            "position.synthiccframe.x",
            "position.synthiccframe.y",
            "position.profile_info.x",
            "position.profile_info.y",
            "position.progress.x",
            "position.progress.y",
            "position.reportframe.x",
            "position.reportframe.y",
            "position.scripting.x",
            "position.scripting.y",
            "position.tcgen.x",
            "position.tcgen.y",
            "recent_cals",
            "report.pack_js",
            "settings.changed",
            "show_advanced_options",
            "show_donation_message",
            "skip_legacy_serial_ports",
            "skip_scripts",
            "sudo.preserve_environment",
            "tc_precond_profile",
            "tc_vrml_cie",
            "tc_vrml_cie_colorspace",
            "tc_vrml_device",
            "tc_vrml_device_colorspace",
            "tc.show",
            "uniformity.measure.continuous",
            "untethered.measure.auto",
            "untethered.measure.manual.delay",
            "untethered.max_delta.chroma",
            "untethered.min_delta",
            "untethered.min_delta.lightness",
            "update_check",
            "webserver.portnumber",
            "whitepoint.visual_editor.bg_v",
            "whitepoint.visual_editor.b",
            "whitepoint.visual_editor.g",
            "whitepoint.visual_editor.r",
            "x3dom.cache",
            "x3dom.embed",
        ]
        override_default = {
            "app.dpi": None,
            "calibration.black_luminance": None,
            "calibration.luminance": None,
            "gamap_src_viewcond": None,
            "gamap_out_viewcond": None,
            "testchart.file": "auto",
            "trc": DEFAULTS["gamma"],
            "whitepoint.colortemp": None,
            "whitepoint.x": None,
            "whitepoint.y": None,
            "3dlut.whitepoint.x": None,
            "3dlut.whitepoint.y": None,
        }
        if override:
            override_default.update(override)
        override = override_default
        for name in DEFAULTS:
            if (
                name not in skip
                and name not in override
                and (
                    len(include) == 0
                    or False in [name.find(item) != 0 for item in include]
                )
                and (
                    len(exclude) == 0
                    or (False not in [name.find(item) != 0 for item in exclude])
                )
            ):
                if name.endswith(".backup") and name == "measurement_mode.backup":
                    setcfg("measurement_mode", getcfg("measurement_mode.backup"))
                default = None
                verbose_print(f"Restoring {name} to {DEFAULTS[name]}", level=3)
                setcfg(name, default)
        for name in override:
            if (
                len(include) == 0 or False in [name.find(item) != 0 for item in include]
            ) and (
                len(exclude) == 0
                or (False not in [name.find(item) != 0 for item in exclude])
            ):
                setcfg(name, override[name])
        if event:
            writecfg()
            self.update_displays()
            self.update_controls()
            self.update_menus()
            if hasattr(self, "tcframe"):
                self.tcframe.tc_update_controls()

    def cal_changed(self, setchanged: bool = True) -> None:
        """Called internally when calibration settings controls are changed.

        Exceptions are the calibration quality and interactive display
        adjustment controls, which do not cause a 'calibration changed' event.

        Args:
            setchanged (bool, optional): If True, sets the 'settings.changed'
                config option to 1, indicating that the settings have changed.
                Defaults to True.
        """
        if self.updatingctrls or not self.IsShownOnScreen():
            return
        # update_controls which is called from cal_changed might cause a
        # another cal_changed call, in which case we can skip it
        debug_print("[D] cal_changed")
        if setchanged:
            setcfg("settings.changed", 1)
        self.worker.options_dispcal = []
        if getcfg("calibration.file", False):
            setcfg("calibration.file", None)
            # Load LUT curves from current display profile (if any, and if
            # it contains curves)
            self.load_display_profile_cal(None)
        self.calibration_file_ctrl.SetStringSelection(lang.getstr("settings.new"))
        self.calibration_file_ctrl.SetToolTip(None)
        self.create_session_archive_btn.Disable()
        self.delete_calibration_btn.Disable()
        self.install_profile_btn.Disable()
        do_update_controls = self.calibration_update_cb.GetValue()
        self.calibration_update_cb.SetValue(False)
        setcfg("calibration.update", 0)
        self.calibration_update_cb.Disable()
        setcfg("profile.update", 0)
        if do_update_controls:
            self.update_controls()
        self.settings_discard_changes(keep_changed_state=True)

    def update_displays(
        self, update_ccmx_items: bool = False, set_height: bool = False
    ) -> None:
        """Update the display selector controls.

        Args:
            update_ccmx_items (bool, optional): If True, updates the
                colorimeter correction matrix items in the colorimeter
                correction matrix control.
            set_height (bool, optional): If True, sets the height of the main
                panel to the best virtual size after updating the displays.
        """
        debug_print("[D] update_displays")
        self.panel.Freeze()
        self.displays = []
        for item in self.worker.displays:
            self.displays.append(
                item.replace("[PRIMARY]", lang.getstr("display.primary"))
            )
            self.displays[-1] = lang.getstr(self.displays[-1])
        self.display_ctrl.SetItems(self.displays)
        self.display_ctrl.Enable(bool(self.worker.displays))
        display_lut_sizer = self.display_ctrl.GetContainingSizer()
        display_sizer = self.display_lut_link_ctrl.GetContainingSizer()
        comport_sizer = self.comport_ctrl.GetContainingSizer()
        if sys.platform not in ("darwin", "win32") or TEST:
            use_lut_ctrl = self.worker.has_separate_lut_access() or bool(
                getcfg("use_separate_lut_access")
            )
            self.GetMenuBar()
            menuitem = self.menu_advanced_options.FindItemById(
                self.menu_advanced_options.FindItem(
                    lang.getstr("use_separate_lut_access")
                )
            )
            menuitem.Check(use_lut_ctrl)
        else:
            use_lut_ctrl = False
        if use_lut_ctrl:
            self.display_lut_ctrl.Clear()
            for i, disp in enumerate(self.displays):
                if self.worker.lut_access[i]:
                    self.display_lut_ctrl.Append(disp)
            comport_sizer.SetCols(1)
            comport_sizer.SetRows(2)
        else:
            comport_sizer.SetCols(2)
            comport_sizer.SetRows(1)
            setcfg("display_lut.link", 1)
        display_lut_sizer.Show(self.display_label, use_lut_ctrl)
        display_lut_sizer.Show(self.display_lut_label, use_lut_ctrl)
        display_lut_sizer.Show(self.display_lut_ctrl, use_lut_ctrl)
        display_sizer.Show(self.display_lut_link_ctrl, use_lut_ctrl)
        self.get_set_display(update_ccmx_items)
        self.calpanel.Layout()
        self.panel.Thaw()
        if self.IsShown():
            self.set_size(set_height)
        self.update_scrollbars()

    def update_scrollbars(self) -> None:
        """Update the scrollbars of the main panel."""
        self.Freeze()
        self.calpanel.SetVirtualSize(self.calpanel.GetBestVirtualSize())
        self.Thaw()

    def update_comports(self, force: bool = False) -> None:
        """Update the comport selector control.

        Args:
            force (bool, optional): If True, forces the update even if the
                comport selector is already populated with the current
                instruments.
        """
        self.comport_ctrl.Freeze()
        self.comport_ctrl.SetItems(
            [
                lang.getstr(
                    "instrument.{}".format(
                        instrument.lower().replace(" ", "_").replace(",", "")
                    ),
                    default=instrument,
                )
                for instrument in self.worker.instruments
            ]
        )
        if self.worker.instruments:
            self.comport_ctrl.SetSelection(
                min(
                    max(0, len(self.worker.instruments) - 1),
                    max(0, int(getcfg("comport.number")) - 1),
                )
            )
        self.comport_ctrl.Enable(bool(self.worker.instruments))
        self.comport_ctrl.Thaw()
        self.comport_ctrl_handler(force=force)

    def update_measurement_mode(self) -> None:
        """Update the measurement mode control."""
        measurement_mode = getcfg("measurement_mode")
        instrument_features = self.worker.get_instrument_features()
        if (
            instrument_features.get("adaptive_mode")
            and (
                self.worker.argyll_version[0:3] > [1, 1, 0]
                or (
                    self.worker.argyll_version[0:3] == [1, 1, 0]
                    and "Beta" not in self.worker.argyll_version_string
                    and "RC1" not in self.worker.argyll_version_string
                    and "RC2" not in self.worker.argyll_version_string
                )
            )
            and getcfg("measurement_mode.adaptive")
        ):
            measurement_mode += "V"
        if instrument_features.get("highres_mode") and getcfg(
            "measurement_mode.highres"
        ):
            measurement_mode += "H"
        self.measurement_mode_ctrl.SetSelection(
            min(
                self.measurement_modes_ba[self.get_instrument_type()].get(
                    measurement_mode, 1
                ),
                len(self.measurement_mode_ctrl.GetItems()) - 1,
            )
        )

    def get_measurement_modes(
        self,
        instrument_name: str,
        instrument_type: str,
        cfgname: str = "measurement_mode",
    ) -> tuple[str, dict, dict, dict]:
        """Get the measurement modes for the given instrument.

        Args:
            instrument_name (str): Name of the instrument.
            instrument_type (str): Type of the instrument (e.g., "spect",
                "colorimeter").
            cfgname (str, optional): Configuration name for the measurement
                mode.

        Returns:
            tuple[str, dict, dict, dict]: A tuple containing two dictionaries:
                - `measurement_mode`: A string representing the current
                    measurement mode.
                - `measurement_modes`: A dictionary mapping instrument types to
                  lists of measurement modes.
                - `measurement_modes_ab`: A dictionary mapping instrument types
                  to lists of abbreviated measurement modes.
                - `measurement_modes_ba`: A dictionary mapping measurement
                  modes to their corresponding indices in the
                  `measurement_modes` list.
        """
        measurement_mode = getcfg(cfgname)
        # if self.get_instrument_type() == "spect":
        # measurement_mode = strtr(measurement_mode, {"c": "", "l": ""})
        if instrument_name != "DTP92":
            measurement_modes = {
                instrument_type: [
                    lang.getstr("measurement_mode.refresh"),
                    lang.getstr("measurement_mode.lcd"),
                ]
            }
            measurement_modes_ab = {instrument_type: ["c", "l"]}
        else:
            measurement_modes = {
                instrument_type: [lang.getstr("measurement_mode.refresh")]
            }
            measurement_modes_ab = {instrument_type: ["c"]}
        instrument_features = self.worker.get_instrument_features(instrument_name)
        if (
            instrument_name in ("Spyder4", "Spyder5")
            and self.worker.spyder4_cal_exists()
        ):
            # Spyder4 Argyll CMS >= 1.3.6
            # Spyder5 Argyll CMS >= 1.7.0
            # See http://www.argyllcms.com/doc/instruments.html#spyd4
            # for description of supported modes
            measurement_modes[instrument_type].extend(
                [
                    lang.getstr("measurement_mode.lcd.ccfl"),
                    lang.getstr("measurement_mode.lcd.wide_gamut.ccfl"),
                    lang.getstr("measurement_mode.lcd.white_led"),
                    lang.getstr("measurement_mode.lcd.wide_gamut.rgb_led"),
                    lang.getstr("measurement_mode.lcd.ccfl.2"),
                ]
            )
            if self.worker.argyll_version >= [1, 5, 0]:
                measurement_modes_ab[instrument_type].extend(["f", "L", "e", "B", "x"])
            else:
                measurement_modes_ab[instrument_type].extend(["3", "4", "5", "6", "7"])
        elif instrument_name == "SpyderX":
            # Argyll SpyderX modes:
            # l General [Default,CB1] (LCD/CCFL)
            # e Standard LED (LCD/white LED)
            # b Wide Gamut LED (LCD/RGB LED)
            # i GB LED (LCD/GB-R Phosphor LED)
            measurement_modes[instrument_type] = [
                lang.getstr("measurement_mode.generic"),
                lang.getstr("measurement_mode.lcd.white_led"),
                lang.getstr("measurement_mode.lcd.wide_gamut.led"),
                lang.getstr("measurement_mode.lcd.wide_gamut.gb_led"),
            ]
            measurement_modes_ab[instrument_type] = ["l", "e", "b", "i"]
        elif instrument_name == "SpyderX2":
            # Argyll SpyderX2 modes: SpyderX plus high brightness
            measurement_modes[instrument_type] = [
                lang.getstr("measurement_mode.generic"),
                lang.getstr("measurement_mode.lcd.white_led"),
                lang.getstr("measurement_mode.lcd.wide_gamut.led"),
                lang.getstr("measurement_mode.lcd.wide_gamut.gb_led"),
                lang.getstr("measurement_mode.lcd.high_brightness", "High brightness"),
            ]
            measurement_modes_ab[instrument_type] = ["l", "e", "b", "i", "h"]
        elif instrument_name == "Spyder 2024":
            # Argyll Spyder/SpyderPro 2024 modes: SpyderX2 plus OLED and Mini-LED
            measurement_modes[instrument_type] = [
                lang.getstr("measurement_mode.generic"),
                lang.getstr("measurement_mode.lcd.white_led"),
                lang.getstr("measurement_mode.lcd.wide_gamut.led"),
                lang.getstr("measurement_mode.lcd.wide_gamut.gb_led"),
                lang.getstr("measurement_mode.lcd.high_brightness", "High brightness"),
                lang.getstr("measurement_mode.lcd.oled", "OLED"),
                lang.getstr("measurement_mode.lcd.mini_led", "Mini-LED"),
            ]
            measurement_modes_ab[instrument_type] = ["l", "e", "b", "i", "h", "o", "m"]
        elif instrument_name in ("ColorHug", "ColorHug2"):
            # Argyll CMS 1.3.6, spectro/colorhug.c, colorhug_disptypesel
            # Note: projector mode (-yp) is not the same as ColorMunki
            # projector mode! (-p)
            # ColorHug2 needs Argyll CMS 1.7
            measurement_modes[instrument_type].extend(
                [
                    lang.getstr("projector"),
                    lang.getstr("measurement_mode.lcd.white_led"),
                    lang.getstr("measurement_mode.factory"),
                    lang.getstr("measurement_mode.raw"),
                    lang.getstr("auto"),
                ]
            )
            measurement_modes_ab[instrument_type].extend(["p", "e", "F", "R", "auto"])
        elif instrument_name == "DTP94" and self.worker.argyll_version >= [1, 5, 0]:
            # Argyll CMS 1.5.x introduces new measurement mode
            measurement_modes[instrument_type].extend(
                [lang.getstr("measurement_mode.generic")]
            )
            measurement_modes_ab[instrument_type].append("g")
        elif instrument_name == "ColorMunki Smile":
            # Only supported in Argyll CMS 1.5.x and newer
            measurement_modes[instrument_type] = [
                lang.getstr("measurement_mode.lcd.ccfl"),
                lang.getstr("measurement_mode.lcd.white_led"),
            ]
            measurement_modes_ab[instrument_type] = ["f", "e"]
        elif instrument_name == "Colorimtre HCFR" and self.worker.argyll_version >= [
            1,
            5,
            0,
        ]:
            # Argyll CMS 1.5.x introduces new measurement mode
            measurement_modes[instrument_type].extend(
                [lang.getstr("measurement_mode.raw")]
            )
            measurement_modes_ab[instrument_type].append("R")
        elif instrument_name == "K-10" or not instrument_features:
            # K-10 and 'unknown' instruments
            measurement_modes[instrument_type] = []
            measurement_modes_ab[instrument_type] = []
            for mode, desc in self.worker.get_instrument_measurement_modes().items():
                measurement_modes[instrument_type].append(lang.getstr(desc))
                measurement_modes_ab[instrument_type].append(mode)
        if (
            instrument_name == "K-10"
            and measurement_mode not in measurement_modes_ab[instrument_type]
        ):
            measurement_mode = "F"
        if instrument_features.get("projector_mode") and self.worker.argyll_version >= [
            1,
            1,
            0,
        ]:
            # Projector mode introduced in Argyll 1.1.0 Beta
            measurement_modes[instrument_type].append(lang.getstr("projector"))
            measurement_modes_ab[instrument_type].append("p")
        if measurement_mode not in measurement_modes_ab[instrument_type]:
            if measurement_modes_ab[instrument_type]:
                measurement_mode = measurement_modes_ab[instrument_type][0]
            else:
                measurement_mode = DEFAULTS["measurement_mode"]
        if instrument_features.get("adaptive_mode") and (
            self.worker.argyll_version[0:3] > [1, 1, 0]
            or (
                self.worker.argyll_version[0:3] == [1, 1, 0]
                and "Beta" not in self.worker.argyll_version_string
                and "RC1" not in self.worker.argyll_version_string
                and "RC2" not in self.worker.argyll_version_string
            )
        ):
            # Adaptive mode introduced in Argyll 1.1.0 RC3
            for key in iter(measurement_modes):
                instrument_modes = list(measurement_modes[key])
                for i, mode in reversed(
                    list(zip(list(range(len(instrument_modes))), instrument_modes))
                ):
                    if mode == lang.getstr("default"):
                        mode = lang.getstr("measurement_mode.adaptive")
                    else:
                        mode = "{} {}".format(
                            mode,
                            lang.getstr("measurement_mode.adaptive"),
                        )
                    measurement_modes[key].insert(i + 1, mode)
                    modesig = measurement_modes_ab[key][i]
                    measurement_modes_ab[key].insert(i + 1, (modesig or "") + "V")
            if getcfg(f"{cfgname}.adaptive"):
                measurement_mode += "V"
        if instrument_features.get("highres_mode"):
            for key in iter(measurement_modes):
                instrument_modes = list(measurement_modes[key])
                for i, mode in reversed(
                    list(zip(list(range(len(instrument_modes))), instrument_modes))
                ):
                    if mode == lang.getstr("default"):
                        mode = lang.getstr("measurement_mode.highres")
                    else:
                        mode = "{} {}".format(
                            mode, lang.getstr("measurement_mode.highres")
                        )
                    measurement_modes[key].insert(i + 1, mode)
                    modesig = measurement_modes_ab[key][i]
                    measurement_modes_ab[key].insert(i + 1, (modesig or "") + "H")
            if getcfg(f"{cfgname}.highres"):
                measurement_mode += "H"
        measurement_modes_ab = dict(
            list(
                zip(
                    list(measurement_modes_ab.keys()),
                    [
                        dict(
                            list(
                                zip(
                                    list(range(len(measurement_modes_ab[key]))),
                                    measurement_modes_ab[key],
                                )
                            )
                        )
                        for key in measurement_modes_ab
                    ],
                )
            )
        )
        measurement_modes_ba = dict(
            list(
                zip(
                    list(measurement_modes_ab.keys()),
                    [
                        swap_dict_keys_values(measurement_modes_ab[key])
                        for key in measurement_modes_ab
                    ],
                )
            )
        )
        return (
            measurement_mode,
            measurement_modes,
            measurement_modes_ab,
            measurement_modes_ba,
        )

    def update_measurement_modes(self) -> None:
        """Populate the measurement mode control."""
        instrument_name = self.worker.get_instrument_name()
        instrument_type = self.get_instrument_type()
        (
            measurement_mode,
            measurement_modes,
            measurement_modes_ab,
            measurement_modes_ba,
        ) = self.get_measurement_modes(instrument_name, instrument_type)
        self.measurement_modes_ab = measurement_modes_ab
        self.measurement_modes_ba = measurement_modes_ba
        self.measurement_mode_ctrl.Freeze()
        self.measurement_mode_ctrl.SetItems(measurement_modes[instrument_type])
        self.measurement_mode_ctrl.SetSelection(
            min(
                self.measurement_modes_ba[instrument_type].get(measurement_mode, 1),
                len(measurement_modes[instrument_type]) - 1,
            )
        )
        measurement_mode = self.get_measurement_mode() or "l"
        if measurement_mode != "auto":
            measurement_mode = measurement_mode[0]
        setcfg("measurement_mode", measurement_mode)
        self.measurement_mode_ctrl.Enable(
            bool(self.worker.instruments) and bool(measurement_modes[instrument_type])
        )
        self.measurement_mode_ctrl.Thaw()

    def update_colorimeter_correction_matrix_ctrl(self) -> None:
        """Show or hide the colorimeter correction matrix controls."""
        self.panel.Freeze()
        self.update_adjustment_controls()
        self.worker.get_instrument_features()
        show_control = (
            self.worker.instrument_can_use_ccxx(False)
            and not is_ccxx_testchart()
            and getcfg("measurement_mode") != "auto"
        )
        self.colorimeter_correction_matrix_ctrl.GetContainingSizer().Show(
            self.colorimeter_correction_matrix_ctrl, show_control
        )
        self.colorimeter_correction_info_btn.GetContainingSizer().Show(
            self.colorimeter_correction_info_btn, show_control
        )
        self.colorimeter_correction_matrix_label.GetContainingSizer().Show(
            self.colorimeter_correction_matrix_label, show_control
        )
        self.colorimeter_correction_matrix_btn.GetContainingSizer().Show(
            self.colorimeter_correction_matrix_btn, show_control
        )
        self.colorimeter_correction_web_btn.GetContainingSizer().Show(
            self.colorimeter_correction_web_btn, show_control
        )
        self.colorimeter_correction_create_btn.ContainingSizer.Show(
            self.colorimeter_correction_create_btn, show_control
        )
        self.calpanel.Layout()
        self.panel.Thaw()
        if self.IsShown():
            wx.CallAfter(self.set_size, True)
            wx.CallLater(1, self.update_scrollbars)

    def delete_colorimeter_correction_matrix_ctrl_item(self, path: str) -> None:
        """Delete a colorimeter correction matrix control item.

        Args:
            path (str): Path to the CCMX/CCSS file to delete.
        """
        if path in self.ccmx_cached_paths:
            self.ccmx_cached_paths.remove(path)
        if path in self.ccmx_cached_descriptors:
            del self.ccmx_cached_descriptors[path]
        if path in self.ccmx_instruments:
            del self.ccmx_instruments[path]
        delete = False
        key = None
        for _key in self.ccmx_mapping:
            value = self.ccmx_mapping[_key]
            if value == path:
                delete = True
                key = _key
                break
        if delete:
            del self.ccmx_mapping[key]

    def update_colorimeter_correction_matrix_ctrl_items(
        self,
        force: bool = False,
        warn_on_mismatch: bool = False,
        update_measurement_mode: bool = True,
    ) -> None:
        """Show selected correction matrix and list all ccmx/ccss files.

        Args:
            force (bool, optional): If True, reads the ccmx directory again,
                otherwise uses a previously cached result if available
            warn_on_mismatch (bool, optional): If True, warns the user if the
                selected CCMX does not match the instrument or display type.
            update_measurement_mode (bool, optional): If True, updates the
                measurement mode control after updating the CCMX items.
        """
        items = [lang.getstr("colorimeter_correction.file.none"), lang.getstr("auto")]
        self.ccmx_item_paths = []
        index = 0
        ccxx_path = None
        ccmx = getcfg("colorimeter_correction_matrix_file").split(":", 1)

        if len(ccmx) > 1 and not os.path.isfile(ccmx[1]):
            ccmx = ccmx[:1]

        if force or not getattr(self, "ccmx_cached_paths", None):
            ccmx_paths = self.get_argyll_data_files("lu", "*.ccmx")
            ccss_paths = self.get_argyll_data_files("lu", "*.ccss")
            # Filter out files with known identical spectra
            # Key is the preferred CCSS, value is the one to be ignored
            # If key is same as value, remove from paths completely
            mapping = {
                "Dell_U2413_25Jul12.ccss": "GBrLED_25Jul12.ccss",  # HCFR
                "necpa242w_full.ccss": "necpa242w_full.ccss",  # HCFR
                # necpa242w_full.ccss is bad - not done with native primaries
                "Panasonic VVX17P051J00.ccss": "PanasonicVVX17P051J00.ccss",
            }
            imapping = {}
            for path in ccss_paths:
                basename = os.path.basename(path)
                if basename in mapping:
                    imapping[mapping[basename]] = path
            if imapping:
                discard_paths = []
                for path in ccss_paths:
                    basename = os.path.basename(path)
                    if basename in imapping:
                        if basename in mapping:
                            print("Ignoring", path)
                        else:
                            print("Ignoring", path, "in favor of", imapping[basename])
                        discard_paths.append(path)
                if discard_paths:
                    ccss_paths = [
                        path for path in ccss_paths if path not in discard_paths
                    ]
            ccmx_paths.sort(key=os.path.basename)
            ccss_paths.sort(key=os.path.basename)
            self.ccmx_cached_paths = ccmx_paths + ccss_paths
            self.ccmx_cached_descriptors = {}
            self.ccmx_instruments = {}
            self.ccmx_mapping = {}
        types = {
            "ccss": lang.getstr("spectral").replace(":", ""),
            "ccmx": lang.getstr("matrix").replace(":", ""),
        }
        add_basename_to_desc_on_mismatch = False
        malformed_ccxx = []
        for _i, path in enumerate(self.ccmx_cached_paths):
            filename, ext = os.path.splitext(path)
            lstr = ext[1:] + "." + os.path.basename(filename)
            desc = lang.getstr(lstr)
            if self.ccmx_cached_descriptors.get(path):
                if desc == lstr:
                    desc = self.ccmx_cached_descriptors[path]
            elif os.path.isfile(path):
                try:
                    cgats = CGATS(path, strict=True)
                except (OSError, CGATSError) as exception:
                    print(exception)
                    if isinstance(exception, CGATSInvalidError):
                        malformed_ccxx.append(path)
                    continue
                if desc == lstr:
                    desc = cgats.get_descriptor()  # this is bytes
                    desc = desc.decode("utf-8", "replace")
                # If the description is not the same as the 'sane'
                # filename, add the filename after the description
                # (max 31 chars)
                # See also colorimeter_correction_check_overwrite, the
                # way the filename is processed must be the same
                if (
                    add_basename_to_desc_on_mismatch
                    and re.sub(
                        r"[\\/:;*?\"<>|]+", "_", make_argyll_compatible_path(desc)
                    )
                    != os.path.splitext(os.path.basename(path))[0]
                ):
                    desc = "{} <{}>".format(
                        ellipsis_(desc, 66, "m"),
                        ellipsis_(os.path.basename(path), 31, "m"),
                    )
                else:
                    desc = ellipsis_(desc, 100, "m")
                self.ccmx_cached_descriptors[path] = desc
                # get_canonical_instrument_name: returns bytes
                self.ccmx_instruments[path] = get_canonical_instrument_name(
                    cgats.queryv1("INSTRUMENT") or b"",
                    {
                        "DTP94-LCD mode": "DTP94",
                        "eye-one display": "i1 Display",
                        "Spyder 2 LCD": "Spyder2",
                        "Spyder 3": "Spyder3",
                    },
                ).decode("utf-8")
                key = "{}\0{}".format(
                    self.ccmx_instruments[path],
                    (cgats.queryv1("DISPLAY") or b"").decode("utf-8"),
                )
                if not self.ccmx_mapping.get(key) or (
                    len(ccmx) > 1 and path == ccmx[1]
                ):
                    # Prefer the selected CCMX
                    self.ccmx_mapping[key] = path
            else:
                continue

            instrument_name = self.worker.get_instrument_name()
            if instrument_name.lower().replace(" ", "") in self.ccmx_instruments.get(
                path, ""
            ).lower().replace(" ", "") or (
                path.lower().endswith(".ccss")
                and self.worker.instrument_supports_ccss()
            ):
                # Only add the correction to the list if it matches the
                # currently selected instrument or if it is a CCSS
                if len(ccmx) > 1 and ccmx[0] != "AUTO" and ccmx[1] == path:
                    ccxx_path = path

                item_text = "{}: {}".format(
                    types.get(os.path.splitext(path)[1].lower()[1:]),
                    desc if isinstance(desc, str) else desc.decode("utf-8", "replace"),
                )
                items.append(item_text)
                self.ccmx_item_paths.append(path)
        items_paths = []
        for i, item in enumerate(items[2:]):
            items_paths.append({"item": item, "path": self.ccmx_item_paths[i]})
        items_paths.sort(key=lambda item_path: item_path["item"].lower())
        for i, item_path in enumerate(items_paths):
            items[i + 2] = item_path["item"]
            self.ccmx_item_paths[i] = item_path["path"]
        if ccxx_path:
            index = self.ccmx_item_paths.index(ccxx_path) + 2
        add_cfg_ccxx = False
        cgats = None
        if (
            len(ccmx) > 1
            and ccmx[1]
            and ccmx[1] not in self.ccmx_cached_paths
            and (
                not ccmx[1].lower().endswith(".ccss")
                or self.worker.instrument_supports_ccss()
            )
        ):
            # Add currently configured CCXX to list? Check if same file in list
            add_cfg_ccxx = True
            for i, path in enumerate(self.ccmx_item_paths):
                if os.path.basename(path) == os.path.basename(ccmx[1]):
                    try:
                        ccxx = CGATS(path)
                        ccxx[0].DATA.vmaxlen = 5  # Allow margin of error
                    except Exception as exception:
                        print(exception)
                        break
                    try:
                        cgats = CGATS(ccmx[1], strict=True)
                        vmaxlen = cgats[0].DATA.vmaxlen
                        cgats[0].DATA.vmaxlen = 5  # Allow margin of error
                    except Exception as exception:
                        show_ccxx_error_dialog(exception, ccmx[1], self)
                        add_cfg_ccxx = False
                        ccmx = [""]
                    else:
                        if str(cgats) == str(ccxx):
                            # Same, use existing entry
                            print(ccmx[1], "matches", path, "- using the latter")
                            add_cfg_ccxx = False
                            ccmx[1] = path
                            index = i + 2
                        else:
                            print(ccmx[1], "does not match", path, "- using the former")
                        cgats[0].DATA.vmaxlen = vmaxlen
                    break
        if add_cfg_ccxx:
            desc = self.ccmx_cached_descriptors.get(ccmx[1])
            if not desc and os.path.isfile(ccmx[1]):
                try:
                    if not cgats:
                        cgats = CGATS(ccmx[1], strict=True)
                except (OSError, CGATSError) as exception:
                    if isinstance(exception, CGATSInvalidError) and ccmx[
                        1
                    ] in self.get_argyll_data_files(
                        "lu", "*" + os.path.splitext(ccmx[1])[1]
                    ):
                        malformed_ccxx.append(ccmx[1])
                    show_ccxx_error_dialog(exception, ccmx[1], self)
                    ccmx = [""]
                else:
                    self.ccmx_cached_paths.insert(0, ccmx[1])
                    desc = cgats.get_descriptor()
                    # If the description is not the same as the 'sane'
                    # filename, add the filename after the description
                    # (max 31 chars)
                    # See also colorimeter_correction_check_overwite, the
                    # way the filename is processed must be the same
                    if (
                        add_basename_to_desc_on_mismatch
                        and re.sub(
                            r"[\\/:;*?\"<>|]+", "_", make_argyll_compatible_path(desc)
                        )
                        != os.path.splitext(os.path.basename(ccmx[1]))[0]
                    ):
                        desc = "{} <{}>".format(
                            ellipsis_(desc, 66, "m"),
                            ellipsis_(os.path.basename(ccmx[1]), 31, "m"),
                        )
                    else:
                        desc = ellipsis_(desc, 100, "m")
                    self.ccmx_cached_descriptors[ccmx[1]] = desc
                    self.ccmx_instruments[ccmx[1]] = get_canonical_instrument_name(
                        cgats.queryv1("INSTRUMENT") or b"",
                        {
                            "DTP94-LCD mode": "DTP94",
                            "eye-one display": "i1 Display",
                            "Spyder 2 LCD": "Spyder2",
                            "Spyder 3": "Spyder3",
                        },
                    ).decode("utf-8")
                    key = "{}\0{}".format(
                        self.ccmx_instruments[ccmx[1]],
                        (cgats.queryv1("DISPLAY") or b"").decode("utf-8"),
                    )
                    self.ccmx_mapping[key] = ccmx[1]
            if desc and (
                self.worker.get_instrument_name().lower().replace(" ", "")
                in self.ccmx_instruments.get(ccmx[1], "").lower().replace(" ", "")
                or ccmx[1].lower().endswith(".ccss")
            ):
                # Only add the correction to the list if it matches the
                # currently selected instrument or if it is a CCSS
                items.insert(
                    2,
                    "{}: {}".format(
                        types.get(os.path.splitext(ccmx[1])[1].lower()[1:]),
                        desc
                        if isinstance(desc, str)
                        else desc.decode("utf-8", "replace"),
                    ),
                )
                self.ccmx_item_paths.insert(0, ccmx[1])
                if ccmx[0] != "AUTO":
                    index = 2
        if ccmx[0] == "AUTO":
            if len(ccmx) < 2:
                ccmx.append("")
            display_name = self.worker.get_display_name(False, True, False)
            if self.worker.instrument_supports_ccss():
                # Prefer CCSS
                ccmx[1] = self.ccmx_mapping.get(f"\0{display_name}", "")
            if not self.worker.instrument_supports_ccss() or not ccmx[1]:
                instrument_name = self.worker.get_instrument_name()
                print(f"instrument_name: {instrument_name}")
                print(f"display_name   : {display_name}")
                ccmx[1] = self.ccmx_mapping.get(
                    f"{instrument_name}\0{display_name}", ""
                )
            cgats = None
        elif not ccmx[0] and len(ccmx) < 2:
            current_index = self.colorimeter_correction_matrix_ctrl.Selection
            if -1 < current_index - 2 < len(self.ccmx_item_paths):
                index = current_index
                ccmx.append(self.ccmx_item_paths[current_index - 2])
        if (
            self.worker.instrument_can_use_ccxx()
            and len(ccmx) > 1
            and ccmx[1]
            and ccmx[1] not in self.ccmx_item_paths
        ):
            # CCMX does not match the currently selected instrument,
            # don't use
            msg = lang.getstr("colorimeter_correction.instrument_mismatch")
            if warn_on_mismatch:
                show_result_dialog(Warn(msg), self)
            else:
                print(msg, ccmx[1])
            ccmx = [""]
        elif ccmx[0] == "AUTO":
            index = 1
            if ccmx[1]:
                ccmx_desc = self.ccmx_cached_descriptors[ccmx[1]]
                items[1] += " ({}: {})".format(
                    types.get(os.path.splitext(ccmx[1])[1].lower()[1:]),
                    (
                        ccmx_desc
                        if isinstance(ccmx_desc, str)
                        else ccmx_desc.decode("utf-8", "replace")
                    ),
                )
            else:
                items[1] += " ({})".format(
                    lang.getstr("colorimeter_correction.file.none")
                )
        use_ccmx = (
            self.worker.instrument_can_use_ccxx(False) and len(ccmx) > 1 and ccmx[1]
        )
        tech = None
        observer = None
        if use_ccmx:
            mode = None
            try:
                if not cgats:
                    cgats = CGATS(ccmx[1], strict=True)
            except (OSError, CGATSError) as exception:
                show_ccxx_error_dialog(exception, ccmx[1], self)
                ccmx = ["", ""]
                index = 0
            else:
                if getcfg("measurement_mode") != "auto":
                    tech = cgats.queryv1("TECHNOLOGY")
                    # Set appropriate measurement mode
                    # IMPORTANT: Make changes aswell in the following locations:
                    # - DisplayCAL.get_cgats_measurement_mode
                    mode = get_cgats_measurement_mode(
                        cgats, self.worker.get_instrument_name()
                    )
                observer = cgats.queryv1("OBSERVER")
                if observer in self.observers_ab:
                    setcfg("observer", observer)
                    self.update_observer_ctrl()
                    self.observer_ctrl.Disable()
            if mode or (
                getcfg("measurement_mode") != "auto"
                and not self.worker.instrument_can_use_ccxx()
            ):
                if update_measurement_mode or mode == getcfg("measurement_mode"):
                    setcfg("measurement_mode", mode)
                    self.update_measurement_mode()
                else:
                    ccmx = ["", ""]
                    index = 0
                    tech = None
        if tech is None:
            tech = self.worker.get_instrument_measurement_modes().get(
                getcfg("measurement_mode")
            )
        setcfg("display.technology", tech)
        setcfg("colorimeter_correction_matrix_file", ":".join(ccmx))
        self.colorimeter_correction_matrix_ctrl.Freeze()
        self.colorimeter_correction_matrix_ctrl.SetItems(items)
        self.colorimeter_correction_matrix_ctrl.SetSelection(index)
        self.colorimeter_correction_matrix_ctrl.Thaw()
        tooltip = ccmx[1] if use_ccmx else ""
        self.update_main_controls()
        self.colorimeter_correction_matrix_ctrl.SetToolTipString(tooltip)
        self.colorimeter_correction_info_btn.Enable(len(ccmx) > 1 and bool(ccmx[1]))
        self.update_estimated_measurement_times()
        if observer not in self.observers_ab:
            self.observer_ctrl.Enable()
        self.show_observer_ctrl()
        if malformed_ccxx:
            show_result_dialog(
                Warn(
                    lang.getstr("argyll.malformed_ccxx")
                    + "\n\n"
                    + "\n".join(malformed_ccxx)
                ),
                (self.Shown and self) or None,
            )
            msg = None
            if sys.platform == "darwin":
                trashcan = lang.getstr("trashcan.mac")
            elif sys.platform == "win32":
                trashcan = lang.getstr("trashcan.windows")
            else:
                trashcan = lang.getstr("trashcan.linux")
            try:
                send2trash(malformed_ccxx)
            except OSError as exc:
                msg = lang.getstr("error.deletion", trashcan) + "\n\n" + str(exc)
            else:
                orphans = [
                    orphan for orphan in malformed_ccxx if os.path.exists(orphan)
                ]
                if orphans:
                    msg = (
                        lang.getstr("error.deletion", trashcan)
                        + "\n\n"
                        + "\n".join(orphans)
                    )
            if msg:
                show_result_dialog(msg, (self.Shown and self) or None)
            elif not add_cfg_ccxx:
                # Need to refresh displays & instruments
                self.Bind(wx.EVT_SHOW, self.check_update_controls_once)

    def check_update_controls_once(self, event: wx.Event) -> None:
        """Check if controls need to be updated once after the panel is shown.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        if not hasattr(self, "_check_update_controls_once"):
            self._check_update_controls_once = True
            wx.CallAfter(self.check_update_controls, event)

    def update_main_controls(self) -> None:
        """Enable/disable calibrate/profile buttons based on Argyll functionality."""
        self.panel.Freeze()

        is_profile_ = is_profile()

        cal = getcfg("calibration.file", False)
        self.install_profile_btn.Enable(
            bool(self.worker.displays) and is_profile_ and cal not in self.presets
        )

        update_cal = self.calibration_update_cb.GetValue()

        self.measurement_mode_ctrl.Enable(
            bool(self.worker.instruments)
            and len(self.measurement_mode_ctrl.GetItems()) > 1
        )

        update_profile = update_cal and is_profile_

        self.visual_whitepoint_editor_btn.Enable(
            bool(self.worker.displays)
            and bool(self.worker.instruments)
            and not update_cal
        )
        self.whitepoint_measure_btn.Enable(
            bool(self.worker.instruments) and not update_cal
        )
        self.ambient_measure_btn.Enable(
            bool(self.worker.instruments) and not update_cal
        )
        self.luminance_measure_btn.Enable(
            bool(self.worker.instruments) and not update_cal
        )
        self.ambient_luminance_measure_btn.Enable(
            bool(self.worker.instruments) and not update_cal
        )
        self.black_luminance_measure_btn.Enable(
            bool(self.worker.instruments) and not update_cal
        )

        lut3d_create_btn_show = self.lut3d_settings_panel.IsShown() and not getcfg(
            "3dlut.create"
        )
        mr_btn_show = self.mr_settings_panel.IsShown()
        enable_cal = not config.is_uncalibratable_display() and (
            self.interactive_display_adjustment_cb.GetValue()
            or self.trc_ctrl.GetSelection() > 0
        )
        calibrate_and_profile_btn_show = (
            not lut3d_create_btn_show
            and not mr_btn_show
            and enable_cal
            and not update_profile
        )
        calibrate_btn_show = (
            not lut3d_create_btn_show and not mr_btn_show and enable_cal
        )
        profile_btn_show = (
            not lut3d_create_btn_show
            and not mr_btn_show
            and not calibrate_and_profile_btn_show
            and not update_cal
        )
        if (
            config.is_uncalibratable_display()
            and self.calibration_settings_panel.IsShown()
        ) or (not getcfg("3dlut.tab.enable") and self.lut3d_settings_panel.IsShown()):
            self.tab_select_handler(self.display_instrument_btn)
        if (
            config.is_uncalibratable_display()
            and not self.calibration_settings_btn.IsEnabled()
        ):
            self.calibration_settings_btn._pressed = False
            self.calibration_settings_btn._SetState(platebtn.PLATE_NORMAL)
        self.calibration_settings_btn.Enable(not config.is_uncalibratable_display())
        self.lut3d_settings_btn.Enable(bool(getcfg("3dlut.tab.enable")))
        self.calibrate_btn.Show(
            not calibrate_and_profile_btn_show and calibrate_btn_show
        )
        self.calibrate_btn.Enable(
            not calibrate_and_profile_btn_show
            and calibrate_btn_show
            and not is_ccxx_testchart()
            and bool(self.worker.displays)
            and bool(self.worker.instruments)
        )
        self.calibrate_and_profile_btn.Show(calibrate_and_profile_btn_show)
        self.calibrate_and_profile_btn.Enable(
            calibrate_and_profile_btn_show
            and not is_ccxx_testchart()
            and bool(self.worker.displays)
            and bool(self.worker.instruments)
        )
        self.profile_btn.Show(profile_btn_show)
        self.profile_btn.Enable(
            profile_btn_show
            and bool(self.worker.displays)
            and bool(self.worker.instruments)
        )
        self.lut3d_create_btn.Show(lut3d_create_btn_show)
        self.measurement_report_btn.Show(mr_btn_show)
        self.buttonpanel.Layout()

        self.lut3d_create_btn.Enable(
            is_profile() and getcfg("calibration.file", False) not in self.presets
        )

        self.panel.Layout()
        self.panel.Thaw()

    def update_calibration_file_ctrl(
        self, silent: bool = False
    ) -> tuple[str, str, str, bool]:
        """Update calibration file control items and tooltip for the selected file.

        Returns:
            tuple[str, str, str, bool]: A tuple containing:
                - The selected calibration file path.
                - The filename without extension.
                - The profile path (filename with .icc or .icm extension).
                - A boolean indicating if the profile exists.
        """
        cal = getcfg("calibration.file", False)

        if cal:
            result = check_file_isfile(cal, silent=silent)
            if isinstance(result, Exception) and not silent:
                show_result_dialog(result, self)
        else:
            result = False
        if not isinstance(result, Exception) and result:
            filename, ext = os.path.splitext(cal)
            if cal not in self.recent_cals:
                self.recent_cals.append(cal)
                recent_cals = self.get_unpreseted_recent_calibrations()
                setcfg("recent_cals", os.pathsep.join(recent_cals))
                self.calibration_file_ctrl.Append(lang.getstr(os.path.basename(cal)))
            # The case-sensitive index could fail because of
            # case insensitive file systems, e.g. if the
            # stored filename string is
            # "C:\Users\Name\AppData\DisplayCAL\storage\MyFile"
            # but the actual filename is
            # "C:\Users\Name\AppData\DisplayCAL\storage\myfile"
            # (maybe because the user renamed the file)
            idx = index_fallback_ignorecase(self.recent_cals, cal)
            self.calibration_file_ctrl.SetSelection(idx)
            self.calibration_file_ctrl.SetToolTipString(cal)
            if ext.lower() in ICCPROFILE_FILE_EXTENSIONS:
                profile_path = cal
            else:
                profile_path = filename + PROFILE_EXT
            profile_exists = os.path.exists(profile_path)
        else:
            filename = None
            if cal in self.recent_cals[1:]:
                # The case-sensitive index could fail because of
                # case insensitive file systems, e.g. if the
                # stored filename string is
                # "C:\Users\Name\AppData\DisplayCAL\storage\MyFile"
                # but the actual filename is
                # "C:\Users\Name\AppData\DisplayCAL\storage\myfile"
                # (maybe because the user renamed the file)
                idx = index_fallback_ignorecase(self.recent_cals, cal)
                self.recent_cals.remove(cal)
                self.calibration_file_ctrl.Delete(idx)
            cal = None
            self.calibration_file_ctrl.SetStringSelection(lang.getstr("settings.new"))
            self.calibration_file_ctrl.SetToolTip(None)
            setcfg("calibration.file", None)
            setcfg("calibration.update", 0)
            profile_path = None
            profile_exists = False

        return cal, filename, profile_path, profile_exists

    def get_unpreseted_recent_calibrations(self) -> list:
        """Return recent calibrations that are not presets.

        Returns:
            list: List of recent calibration file paths that are not presets.
        """
        return [
            recent_cal
            for recent_cal in self.recent_cals
            if recent_cal not in self.presets
        ]

    def update_controls(
        self,
        update_profile_name: bool = True,
        update_ccmx_items: bool = True,
        silent: bool = False,
    ) -> None:
        """Update controls based on configuration and Argyll functionality.

        Args:
            update_profile_name (bool, optional): If True, updates the profile
                name control.
            update_ccmx_items (bool, optional): If True, updates the
                colorimeter correction matrix control items.
            silent (bool, optional): If True, suppresses error dialogs.
        """
        self.updatingctrls = True

        self.panel.Freeze()

        (
            cal,
            filename,
            profile_path,
            profile_exists,
        ) = self.update_calibration_file_ctrl(silent)
        self.create_session_archive_btn.Enable(bool(cal) and cal not in self.presets)
        self.delete_calibration_btn.Enable(bool(cal) and cal not in self.presets)
        is_profile_ = is_profile(include_display_profile=True)
        self.profile_info_btn.Enable(is_profile_)
        enable_update = (
            bool(cal)
            and os.path.exists(filename + ".cal")
            and can_update_cal(filename + ".cal")
        )
        if not enable_update:
            setcfg("calibration.update", 0)
        update_cal = getcfg("calibration.update")
        self.calibration_update_cb.Enable(enable_update)
        self.calibration_update_cb.SetValue(bool(update_cal))

        if not update_cal or not profile_exists:
            setcfg("profile.update", 0)

        update_profile = update_cal and profile_exists
        enable_profile = not update_profile

        if update_ccmx_items:
            self.update_colorimeter_correction_matrix_ctrl_items()

        self.update_measurement_mode()

        self.update_observer_ctrl()

        for name in ("min_display_update_delay_ms", "display_settle_time_mult"):
            value = bool(getcfg(f"measure.override_{name}"))
            getattr(self, f"override_{name}").SetValue(value)
            self.update_display_delay_ctrl(name, value)

        self.update_ffp_insertion_ctrl()
        self.ffp_insertion_interval.SetValue(
            getcfg("patterngenerator.ffp_insertion.interval")
        )
        self.ffp_insertion_duration.SetValue(
            getcfg("patterngenerator.ffp_insertion.duration")
        )
        self.ffp_insertion_level.SetValue(
            int(getcfg("patterngenerator.ffp_insertion.level") * 100)
        )

        self.update_adjustment_controls()
        self.whitepoint_colortemp_textctrl.Enable(not update_cal)
        self.whitepoint_colortemp_locus_ctrl.Enable(not update_cal)
        self.whitepoint_x_textctrl.Enable(not update_cal)
        self.whitepoint_y_textctrl.Enable(not update_cal)
        self.luminance_textctrl.Enable(not update_cal)
        self.black_luminance_textctrl.Enable(not update_cal)
        self.trc_ctrl.Enable(not update_cal)
        self.trc_textctrl.Enable(not update_cal)
        self.trc_type_ctrl.Enable(not update_cal)
        self.ambient_viewcond_adjust_cb.Enable(not update_cal)
        self.black_output_offset_ctrl.Enable(not update_cal)
        self.black_output_offset_intctrl.Enable(not update_cal)
        self.black_point_correction_auto_cb.Enable(not update_cal)
        self.black_point_correction_ctrl.Enable(not update_cal)
        self.black_point_correction_intctrl.Enable(not update_cal)
        self.update_black_point_rate_ctrl()
        self.update_drift_compensation_ctrls()

        self.testchart_btn.Enable(enable_profile)
        self.testchart_patches_amount_ctrl.Enable(enable_profile)
        self.create_testchart_btn.Enable(enable_profile)
        self.profile_type_ctrl.Enable(enable_profile)

        self.whitepoint_colortemp_locus_ctrl.SetSelection(
            self.whitepoint_colortemp_loci_ba.get(
                getcfg("whitepoint.colortemp.locus"),
                self.whitepoint_colortemp_loci_ba.get(
                    DEFAULTS["whitepoint.colortemp.locus"]
                ),
            )
        )

        self.whitelevel_drift_compensation.SetValue(
            bool(getcfg("drift_compensation.whitelevel"))
        )

        self.blacklevel_drift_compensation.SetValue(
            bool(getcfg("drift_compensation.blacklevel"))
        )

        trc = getcfg("trc")
        bt1886 = (
            trc == 2.4
            and getcfg("trc.type") == "G"
            and getcfg("calibration.black_output_offset") == 0
        )
        if trc in ("l", "709", "240", "s"):
            self.trc_type_ctrl.SetSelection(0)
        if trc == "l":
            self.trc_ctrl.SetSelection(2)
        elif trc == "709":
            self.trc_ctrl.SetSelection(3)
        elif trc == "240":
            self.trc_ctrl.SetSelection(5)
        elif trc == "s":
            self.trc_ctrl.SetSelection(6)
        elif bt1886:
            self.trc_ctrl.SetSelection(4)
            self.trc_textctrl.SetValue(str(trc))
            self.trc_type_ctrl.SetSelection(1)
        else:
            if trc:
                if (
                    trc == 2.2
                    and getcfg("trc.type") == "g"
                    and getcfg("calibration.black_output_offset") == 1
                ):
                    # Gamma 2.2 relative 100% output offset
                    self.trc_ctrl.SetSelection(1)
                else:
                    # Custom
                    self.trc_ctrl.SetSelection(7)
                self.trc_textctrl.SetValue(str(trc))
            else:
                self.trc_ctrl.SetSelection(0)
            self.trc_type_ctrl.SetSelection(
                self.trc_types_ba.get(
                    getcfg("trc.type"), self.trc_types_ba.get(DEFAULTS["trc.type"])
                )
            )
        self.show_trc_controls()

        self.ambient_viewcond_adjust_cb.SetValue(
            bool(int(getcfg("calibration.ambient_viewcond_adjust")))
        )
        self.ambient_viewcond_adjust_textctrl.SetValue(
            getcfg("calibration.ambient_viewcond_adjust.lux")
        )
        self.ambient_viewcond_adjust_textctrl.Enable(
            not update_cal and bool(int(getcfg("calibration.ambient_viewcond_adjust")))
        )

        self.update_profile_type_ctrl()

        self.update_black_output_offset_ctrl()

        self.black_point_correction_ctrl.SetValue(
            int(Decimal(str(getcfg("calibration.black_point_correction"))) * 100)
        )
        self.black_point_correction_intctrl.SetValue(
            int(Decimal(str(getcfg("calibration.black_point_correction"))) * 100)
        )

        self.black_point_rate_ctrl.SetValue(
            int(Decimal(str(getcfg("calibration.black_point_rate"))) * 100)
        )
        self.black_point_rate_floatctrl.SetValue(getcfg("calibration.black_point_rate"))

        q = self.quality_ba.get(
            getcfg("calibration.quality"),
            self.quality_ba.get(DEFAULTS["calibration.quality"]),
        )
        self.calibration_quality_ctrl.SetValue(q)
        self.set_calibration_quality_label(self.quality_ab[q])

        self.update_bpc(enable_profile)

        self.testchart_ctrl.Enable(enable_profile)
        if self.set_default_testchart() is None:
            self.set_testchart(update_profile_name=not update_profile_name)

        self.testchart_patch_sequence_ctrl.SetStringSelection(
            lang.getstr("testchart." + getcfg("testchart.patch_sequence"))
        )

        simple_gamma_model = self.get_profile_type() in ("g", "G")
        if simple_gamma_model:
            q = 3
        else:
            q = (
                self.quality_ba.get(
                    getcfg("profile.quality"),
                    self.quality_ba.get(DEFAULTS["profile.quality"]),
                )
                - 1
            )
        self.profile_quality_ctrl.SetValue(q)
        if q == 1:
            self.profile_quality_info.SetLabel(lang.getstr("calibration.quality.low"))
        elif q == 2:
            self.profile_quality_info.SetLabel(
                lang.getstr("calibration.quality.medium")
            )
        elif q == 3:
            self.profile_quality_info.SetLabel(lang.getstr("calibration.quality.high"))
        elif q == 4:
            self.profile_quality_info.SetLabel(lang.getstr("calibration.quality.ultra"))
        self.profile_quality_ctrl.Enable(enable_profile and not simple_gamma_model)

        enable_gamap = self.get_profile_type() in ("l", "x", "X")
        self.gamap_btn.Enable(enable_profile and enable_gamap)

        if getattr(self, "extra_args", None):
            self.extra_args.update_controls()

        if hasattr(self, "gamapframe"):
            self.gamapframe.update_controls()

        self.mr_set_filebrowse_paths()

        if self.lut3d_settings_panel.IsShown() or self.mr_settings_panel.IsShown():
            if self.mr_settings_panel.IsShown():
                self.mr_update_controls(False)
            else:
                self.set_profile("output")

        self.lut3d_update_controls()
        if getattr(self, "lut3dframe", None):
            self.lut3dframe.update_controls()

        if getattr(self, "reportframe", None):
            self.reportframe.update_controls()

        if update_profile_name:
            self.profile_name_textctrl.ChangeValue(getcfg("profile.name"))
            self.update_profile_name()

        self.update_main_controls()
        self.panel.Thaw()
        self.updatingctrls = False

    def update_trc_control(self) -> None:
        """Update the TRC control based on the current configuration."""
        if self.trc_ctrl.GetSelection() not in (1, 4, 7):
            return
        if (
            getcfg("trc.type") == "G"
            and getcfg("calibration.black_output_offset") == 0
            and getcfg("trc") == 2.4
        ):
            self.trc_ctrl.SetSelection(4)  # BT.1886
        elif (
            getcfg("trc.type") == "g"
            and getcfg("calibration.black_output_offset") == 1
            and getcfg("trc") == 2.2
        ):
            # Gamma 2.2 relative 100% output offset
            self.trc_ctrl.SetSelection(1)
        else:
            self.trc_ctrl.SetSelection(7)  # Custom

    def update_use_video_lut(self) -> None:
        """Update the use video LUT checkbox based on the current display."""
        # Check if the selected display is a pattern generator. If so,
        # don't use videoLUT for calibration. Restore previous value
        # when switching back to a display with videoLUT access.
        is_patterngenerator = config.is_patterngenerator()
        setcfg_cond(is_patterngenerator, "calibration.use_video_lut", 0)
        if not is_patterngenerator and sys.platform == "darwin":
            # macOS video levels encoding seems to only work right on
            # some machines if not using videoLUT to do the scaling
            setcfg_cond(
                getcfg("patterngenerator.use_video_levels"),
                "calibration.use_video_lut",
                0,
            )
            self.menuitem_do_not_use_video_lut.Check(
                not bool(getcfg("calibration.use_video_lut"))
            )

    def show_trc_controls(self, freeze: bool = False) -> None:
        """Show or hide TRC controls based on the selected TRC type.

        Args:
            freeze (bool): Whether to freeze the panel while updating controls.
        """
        show_advanced_options = bool(getcfg("show_advanced_options"))
        if freeze:
            self.panel.Freeze()
        for ctrl in (self.trc_gamma_label, self.trc_textctrl, self.trc_type_ctrl):
            ctrl.Show(
                self.trc_ctrl.GetSelection() == 7
                or (self.trc_ctrl.GetSelection() in (1, 4) and show_advanced_options)
            )
        for ctrl in (
            self.black_output_offset_label,
            self.black_output_offset_ctrl,
            self.black_output_offset_intctrl,
            self.black_output_offset_intctrl_label,
        ):
            ctrl.Show(
                self.trc_ctrl.GetSelection() == 7
                or (self.trc_ctrl.GetSelection() > 0 and show_advanced_options)
            )
        for ctrl in (
            self.ambient_viewcond_adjust_cb,
            self.ambient_viewcond_adjust_textctrl,
            self.ambient_viewcond_adjust_textctrl_label,
            self.ambient_measure_btn,
        ):
            ctrl.GetContainingSizer().Show(
                ctrl,
                # Rec. 709/SMPTE 240M
                self.trc_ctrl.GetSelection() in (3, 5)
                or (self.trc_ctrl.GetSelection() > 0 and show_advanced_options),
            )
        for ctrl in (
            self.black_point_correction_label,
            self.black_point_correction_auto_cb,
        ):
            ctrl.GetContainingSizer().Show(
                ctrl, self.trc_ctrl.GetSelection() > 0 and show_advanced_options
            )
        self.update_black_point_rate_ctrl()
        for ctrl in (
            self.calibration_quality_label,
            self.calibration_quality_ctrl,
            self.calibration_quality_info,
            self.cal_meas_time,
        ):
            ctrl.GetContainingSizer().Show(ctrl, self.trc_ctrl.GetSelection() > 0)
        # Make the height of the last row in the calibration settings sizer
        # match the other rows
        minheight = self.trc_ctrl.Size[1] + 8 if self.trc_ctrl.GetSelection() > 0 else 0
        self.calibration_quality_ctrl.ContainingSizer.SetMinSize((0, minheight))
        self.black_point_correction_auto_handler()
        if freeze:
            self.panel.Thaw()

    def check_show_macos_bugs_warning(
        self, cal: bool = True, profile: bool = True
    ) -> None:
        """Warn about specific macOS bugs.

        Args:
            cal (bool, optional): Whether to check for calibration-related
                bugs.
            profile (bool, optional): Whether to check for profile-related
                bugs.
        """
        if sys.platform != "darwin" or intlist(platform.mac_ver()[0].split(".")) < [
            10,
            8,
        ]:
            # We assume these macOS bugs exist since 10.8 "Mountain Lion"
            return None
        result = None
        if cal and (
            getcfg("calibration.black_point_correction.auto")
            or getcfg("calibration.black_point_correction")
            or getcfg("calibration.black_luminance", False)
        ):  # Warn about calibration bugs
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("macos.bugs.cal.warning"),
                ok=lang.getstr("yes"),
                alt=lang.getstr("no"),
                bitmap=get_icon(32, "dialog-warning"),
            )
            result = dlg.ShowModal()
            dlg.Destroy()
            if result == wx.ID_OK:
                self.black_luminance_ctrl.SetSelection(0)
                self.black_luminance_ctrl_handler(
                    CustomEvent(wx.EVT_CHOICE.evtType[0], self.black_luminance_ctrl)
                )
                setcfg("calibration.black_point_correction.auto", 0)
                setcfg("calibration.black_point_correction", 0)
                self.black_point_correction_ctrl.SetValue(0)
                self.black_point_correction_intctrl.SetValue(0)
                self.black_point_correction_auto_handler()
                self.update_black_point_rate_ctrl()
            elif result == wx.ID_CANCEL:
                return False
        if not profile or not (
            getcfg("profile.type") != "S"
            or not getcfg("profile.black_point_compensation")
        ):
            return None
        # Warn about profile bugs
        dlg = ConfirmDialog(
            self,
            msg=lang.getstr("macos.bugs.profile.warning"),
            ok=lang.getstr("yes"),
            alt=lang.getstr("no"),
            bitmap=get_icon(32, "dialog-warning"),
        )
        result = dlg.ShowModal()
        dlg.Destroy()
        if result == wx.ID_OK:
            setcfg("profile.type", "S")
            setcfg("profile.black_point_compensation", 1)
            self.update_profile_type_ctrl()
            self.update_bpc()
        elif result == wx.ID_CANCEL:
            return False
        return None

    def update_black_output_offset_ctrl(self) -> None:
        """Update the black output offset controls based on configuration."""
        self.black_output_offset_ctrl.SetValue(
            int(Decimal(str(getcfg("calibration.black_output_offset"))) * 100)
        )
        self.black_output_offset_intctrl.SetValue(
            int(Decimal(str(getcfg("calibration.black_output_offset"))) * 100)
        )

    def update_black_point_rate_ctrl(self) -> None:
        """Update the visibility and state of the black point rate controls."""
        self.panel.Freeze()
        enable = not (self.calibration_update_cb.GetValue())
        show = (
            self.trc_ctrl.GetSelection() > 0
            and bool(getcfg("show_advanced_options"))
            and DEFAULTS["calibration.black_point_rate.enabled"]
        )
        self.black_point_rate_label.GetContainingSizer().Show(
            self.black_point_rate_label, show
        )
        self.black_point_rate_ctrl.GetContainingSizer().Show(
            self.black_point_rate_ctrl, show
        )
        self.black_point_rate_ctrl.Enable(
            enable
            and getcfg("calibration.black_point_correction") < 1
            and DEFAULTS["calibration.black_point_rate.enabled"]
        )
        self.black_point_rate_floatctrl.GetContainingSizer().Show(
            self.black_point_rate_floatctrl, show
        )
        self.black_point_rate_floatctrl.Enable(
            enable
            and getcfg("calibration.black_point_correction") < 1
            and DEFAULTS["calibration.black_point_rate.enabled"]
        )
        self.calpanel.Layout()
        self.panel.Thaw()

    def update_bpc(self, enable_profile: bool = True) -> None:
        """Update the black point compensation controls based on configuration.

        Args:
            enable_profile (bool): Whether to enable black point compensation
                controls based on the profile type and other settings.
        """
        enable_bpc = (
            self.get_profile_type() in ("s", "S")
            or (
                self.get_profile_type() in ("l", "x", "X")
                and (
                    getcfg("profile.b2a.hires")
                    or getcfg("profile.quality.b2a") in ("l", "n")
                )
            )
        ) and enable_profile
        if not enable_bpc:
            setcfg("profile.black_point_compensation", 0)
        self.black_point_compensation_cb.Enable(enable_bpc)
        self.black_point_compensation_cb.SetValue(
            enable_bpc and bool(int(getcfg("profile.black_point_compensation")))
        )

    def update_drift_compensation_ctrls(self) -> None:
        """Update the visibility and state of the drift compensation controls."""
        self.panel.Freeze()
        not_untethered = config.get_display_name(None, True) != "Untethered"
        self.blacklevel_drift_compensation.GetContainingSizer().Show(
            self.blacklevel_drift_compensation,
            self.worker.argyll_version >= [1, 3, 0] and not_untethered,
        )
        self.whitelevel_drift_compensation.GetContainingSizer().Show(
            self.whitelevel_drift_compensation,
            self.worker.argyll_version >= [1, 3, 0] and not_untethered,
        )
        self.calpanel.Layout()
        self.panel.Thaw()

    def update_estimated_measurement_time(self, which: str) -> None:
        """Update the estimated measurement time shown.

        Args:
            which (str): The type of measurement to estimate time for.
                Valid values are "testchart", "cal", and "chart".
        """
        if which == "testchart":
            patches = int(self.testchart_patches_amount.Label)
        elif which == "cal":
            # See dispcal.c
            isteps, rsteps, maxits, mxrpts = {
                "v": (10, 16, 1, 10),
                "l": (12, 32, 2, 10),
                "m": (16, 64, 3, 12),
                "h": (20, 96, 4, 16),
                "u": (24, 128, 5, 24),
            }[getcfg("calibration.quality")]

            # 1st iteration
            rsteps /= 1 << (maxits - 1)
            patches = rsteps
            # 2nd..nth iteration
            for _i in range(maxits - 1):
                rsteps *= 2
                patches += rsteps
            # Multiply by estimated repeats
            patches *= mxrpts / 1.5
            # Amount of precal patches is always 9
            patches += 9
            # Initial amount of cal patches is always isteps * 4
            patches += isteps * 4

            # Adjust by dark integration time (scale factor)
            integration_time = self.worker.get_instrument_features().get(
                "integration_time"
            )
            if integration_time and (
                sum(integration_time) / float(len(integration_time))
                == integration_time[0]
            ):  # Check for fixed integration time
                # This helps estimation for instruments with fixed
                # integration time (e.g. SpyderX)
                patches *= float(integration_time[0]) / 2.45
                patches = round(patches)
        elif which == "chart":
            patches = int(self.chart_patches_amount.Label)
        ReportFrame.update_estimated_measurement_time(self, which, patches)

    def update_estimated_measurement_times(self) -> None:
        """Update the estimated measurement times for calibration and profiling."""
        self.update_estimated_measurement_time("cal")
        self.update_estimated_measurement_time("testchart")
        self.update_estimated_measurement_time("chart")

    def update_ffp_insertion_ctrl(self) -> None:
        """Update the FFP insertion controls based on configuration."""
        ffp_insertion = bool(getcfg("patterngenerator.ffp_insertion"))
        self.ffp_insertion.SetValue(ffp_insertion)
        for ctrl in (
            self.ffp_insertion_interval_label,
            self.ffp_insertion_interval,
            self.ffp_insertion_interval_s_label,
            self.ffp_insertion_duration_label,
            self.ffp_insertion_duration,
            self.ffp_insertion_duration_s_label,
            self.ffp_insertion_level_label,
            self.ffp_insertion_level,
            self.ffp_insertion_level_percentage_label,
        ):
            ctrl.Enable(ffp_insertion)

    def blacklevel_drift_compensation_handler(self, event: wx.Event) -> None:
        """Handle the blacklevel drift compensation checkbox event.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        setcfg(
            "drift_compensation.blacklevel",
            int(self.blacklevel_drift_compensation.GetValue()),
        )
        self.update_estimated_measurement_times()

    def whitelevel_drift_compensation_handler(self, event: wx.Event) -> None:
        """Handle the whitelevel drift compensation checkbox event.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        setcfg(
            "drift_compensation.whitelevel",
            int(self.whitelevel_drift_compensation.GetValue()),
        )
        self.update_estimated_measurement_times()

    def calibration_update_ctrl_handler(self, event: wx.Event) -> None:
        """Handle the calibration update checkbox event.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        debug_print(
            "[D] calibration_update_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        setcfg("calibration.update", int(self.calibration_update_cb.GetValue()))
        setcfg(
            "profile.update",
            int(self.calibration_update_cb.GetValue() and is_profile()),
        )
        self.update_controls()

    def enable_spyder2_handler(
        self,
        event: wx.Event,
        check_instrument_setup: bool = False,
        callafter: None | Callable = None,
        callafter_args: None | tuple = None,
    ) -> None | bool:
        """Handle the enable Spyder2 button click event.

        Args:
            event (wx.Event): The event that triggered this handler.
            check_instrument_setup (bool, optional): Whether to check
                instrument setup.
            callafter (None | Callable, optional): A function to call after
                enabling Spyder2.
            callafter_args (tuple, optional): Arguments to pass to the
                callafter function.

        Returns:
            None | bool: None if cancelled, True if successful, or
                a boolean indicating the success of the operation.
        """
        self.update_menus()
        if not check_set_argyll_bin():
            return None
        msg = lang.getstr("oem.import.auto")
        if sys.platform == "win32":
            msg = " ".join([lang.getstr("oem.import.auto_windows"), msg])
        dlg = ConfirmDialog(
            self,
            title=lang.getstr("enable_spyder2"),
            msg=msg,
            ok=lang.getstr("auto"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, "dialog-information"),
            alt=lang.getstr("file.select"),
        )
        needroot = self.worker.argyll_version < [1, 2, 0]
        dlg.install_user = wx.RadioButton(
            dlg, -1, lang.getstr("install_user"), style=wx.RB_GROUP
        )
        dlg.install_user.Enable(not needroot)
        dlg.install_user.SetValue(not needroot)
        dlg.sizer3.Add(dlg.install_user, flag=wx.TOP | wx.ALIGN_LEFT, border=16)
        dlg.install_systemwide = wx.RadioButton(
            dlg, -1, lang.getstr("install_local_system")
        )
        dlg.install_user.Enable(not needroot)
        dlg.install_systemwide.SetValue(needroot)
        dlg.install_user.Bind(wx.EVT_RADIOBUTTON, install_scope_handler)
        dlg.install_systemwide.Bind(wx.EVT_RADIOBUTTON, install_scope_handler)
        install_scope_handler(dlg=dlg)
        dlg.sizer3.Add(dlg.install_systemwide, flag=wx.TOP | wx.ALIGN_LEFT, border=4)
        dlg.sizer0.SetSizeHints(dlg)
        dlg.sizer0.Layout()
        choice = dlg.ShowModal() if event else wx.ID_OK
        asroot = dlg.install_systemwide.GetValue()
        dlg.Destroy()
        if choice == wx.ID_CANCEL:
            return None
        if choice == wx.ID_OK:
            # Auto
            path = None
        else:
            # Prompt for installer executable
            default_dir, default_file = expanduseru("~"), ""
            dlg = wx.FileDialog(
                self,
                lang.getstr("file.select"),
                defaultDir=default_dir,
                defaultFile=default_file,
                wildcard=lang.getstr("filetype.any") + "|*",
                style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
            )
            dlg.Center(wx.BOTH)
            result = dlg.ShowModal()
            path = dlg.GetPath()
            dlg.Destroy()
            if result != wx.ID_OK:
                return None
        if asroot:
            result = self.worker.authenticate(
                get_argyll_util("spyd2en"), lang.getstr("enable_spyder2"), self
            )
            if result not in (True, None):
                if isinstance(result, Exception):
                    show_result_dialog(result, self)
                return None
        self.worker.start(
            self.enable_spyder2_consumer,
            self.enable_spyder2_producer,
            cargs=(check_instrument_setup, callafter, callafter_args),
            wargs=(path, asroot),
            progress_msg=lang.getstr("enable_spyder2"),
            fancy=False,
        )
        return (event and None) or True

    def enable_spyder2(self, path: str, asroot: bool) -> bool | str:
        """Enable Spyder2 by running the spyd2en utility.

        Args:
            path (str): Path to the Spyder2 installer executable.
            asroot (bool): Whether to install system-wide (as root) or
                user-wide.

        Raises:
            Exception: If an error occurs during the enabling process.

        Returns:
            bool | str: True if Spyder2 firmware exists, False if not, or
                a string with the path to the Spyder2 firmware if successful.
        """
        cmd, args = get_argyll_util("spyd2en"), ["-v"]
        if asroot and self.worker.argyll_version >= [1, 2, 0]:
            args.append("-Sl")
        if path:
            args.append(path)
        result = self.worker.exec_cmd(
            cmd,
            args,
            capture_output=True,
            skip_scripts=True,
            silent=False,
            asroot=asroot,
            title=lang.getstr("enable_spyder2"),
        )
        if asroot and sys.platform == "win32":
            # Wait for async process
            sleep(1)
        if result and not isinstance(result, Exception):
            result = self.worker.spyder2_firmware_exists(scope="l" if asroot else "u")
        return result

    def enable_spyder2_producer(self, path: str, asroot: bool) -> None | str:
        """Producer for enabling Spyder2.

        Args:
            path (str): Path to the Spyder2 installer executable.
            asroot (bool): Whether to install system-wide (as root) or
                user-wide.

        Raises:
            Exception: If an error occurs during the enabling process.

        Returns:
            None | str: Path to the Spyder2 firmware if successful, None if
                cancelled.
        """
        if path:
            return self.enable_spyder2(path, asroot)

        if sys.platform in ("darwin", "win32"):
            # Look for Spyder.lib/CVSpyder.dll ourself because spyd2en
            # will only try some fixed paths
            if sys.platform == "darwin":
                wildcard = os.path.join(
                    os.path.sep,
                    "Applications",
                    "Spyder2*",
                    "Spyder2*.app",
                    "Contents",
                    "MacOSClassic",
                    "Spyder.lib",
                )
            else:
                wildcard = os.path.join(
                    getenvu("PROGRAMFILES", ""),
                    "ColorVision",
                    "Spyder2*",
                    "CVSpyder.dll",
                )
            safe_glob_wildcard = safe_glob(wildcard)
            path = safe_glob_wildcard[0] if safe_glob_wildcard else None
        if getcfg("dry_run"):
            return None
        if path:
            result = self.enable_spyder2(path, asroot)
            if result and not isinstance(result, Exception):
                return result
        # Download from web
        path = self.worker.download(f"https://{DOMAIN}/spyd2")
        if isinstance(path, Exception):
            return path
        if not path:
            # Cancelled
            return None
        return self.enable_spyder2(path, asroot)

    def enable_spyder2_consumer(
        self,
        result: bool | str | Exception,
        check_instrument_setup: bool,
        callafter: None | Callable = None,
        callafter_args: tuple = (),
    ) -> None:
        """Consumer for enabling Spyder2.

        Args:
            result (bool | str | Exception): Result of the enabling process.
            check_instrument_setup (bool): Whether to check instrument setup.
            callafter (None | Callable, optional): A function to call after
                enabling Spyder2.
            callafter_args (tuple, optional): Arguments to pass to the
                callafter function.
        """
        if not isinstance(result, Exception) and result:
            result = UnloggedInfo(lang.getstr("enable_spyder2_success"))
            self.update_menus()
        elif result is False:
            result = UnloggedError("".join(self.worker.errors))
        if result:
            show_result_dialog(result, self)
        if check_instrument_setup:
            self.check_instrument_setup(callafter, callafter_args)
        elif callafter:
            wx.CallAfter(callafter, *callafter_args)

    def extra_args_handler(self, event: wx.Event) -> None:
        """Handle the extra arguments menu item.

        Args:
            event (wx.Event): The event triggered by the menu item.
        """
        if not hasattr(self, "extra_args"):
            self.extra_args = ExtraArgsFrame(self)
            self.extra_args.Center()
        if self.extra_args.IsShownOnScreen():
            self.extra_args.Raise()
        else:
            self.extra_args.Show()

    def startup_sound_enable_handler(self, event: wx.Event) -> None:
        """Handle the startup sound enable menu item.

        Args:
            event (wx.Event): The event triggered by the menu item.
        """
        setcfg("startup_sound.enable", int(self.menuitem_startup_sound.IsChecked()))

    def use_simple_splash_handler(self, event: wx.Event) -> None:
        """Handle the simple splash screen menu item.

        Args:
            event (wx.Event): The event triggered by the menu item.
        """
        setcfg("splash.simple", int(self.menuitem_use_simple_splash.IsChecked()))

    def use_fancy_progress_handler(self, event: wx.Event) -> None:
        """Handle the use fancy progress menu item.

        Args:
            event (wx.Event): The event triggered by the menu item.
        """
        setcfg("use_fancy_progress", int(self.menuitem_use_fancy_progress.IsChecked()))

    def use_separate_lut_access_handler(self, event: wx.Event) -> None:
        """Handle the use separate LUT access menu item.

        Args:
            event (wx.Event): The event triggered by the menu item.
        """
        setcfg(
            "use_separate_lut_access",
            int(self.menuitem_use_separate_lut_access.IsChecked()),
        )
        self.update_displays(set_height=True)

    def do_not_use_video_lut_handler(self, event: wx.Event) -> None:
        """Handle the do not use video LUT menu item.

        Args:
            event (wx.Event): The event triggered by the menu item.
        """
        do_not_use_video_lut = self.menuitem_do_not_use_video_lut.IsChecked()
        is_patterngenerator = config.is_patterngenerator()
        if do_not_use_video_lut != is_patterngenerator:
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("calibration.do_not_use_video_lut.warning"),
                ok=lang.getstr("yes"),
                cancel=lang.getstr("no"),
                bitmap=get_icon(32, "dialog-warning"),
                log=False,
            )
            result = dlg.ShowModal()
            dlg.Destroy()
            if result != wx.ID_OK:
                self.menuitem_do_not_use_video_lut.Check(is_patterngenerator)
                return
        setcfg("calibration.use_video_lut", int(not do_not_use_video_lut))
        if not is_patterngenerator:
            setcfg("calibration.use_video_lut.backup", None)

    def skip_legacy_serial_ports_handler(self, event: wx.Event) -> None:
        """Handle the skip legacy serial ports menu item.

        Args:
            event (wx.Event): The event triggered by the menu item.
        """
        setcfg(
            "skip_legacy_serial_ports",
            int(self.menuitem_skip_legacy_serial_ports.IsChecked()),
        )

    def calibrate_instrument_handler(self, event: wx.Event) -> None:
        """Handle the calibrate instrument menu item.

        Args:
            event (wx.Event): The event triggered by the menu item.
        """
        self.worker.start(
            lambda result: (
                show_result_dialog(result, self)
                if isinstance(result, Exception)
                else None
            ),
            self.worker.calibrate_instrument_producer,
            fancy=False,
        )

    def allow_skip_sensor_cal_handler(self, event: wx.Event) -> None:
        """Handle the allow skip sensor calibration menu item.

        Args:
            event (wx.Event): The event triggered by the menu item.
        """
        setcfg(
            "allow_skip_sensor_cal",
            int(self.menuitem_allow_skip_sensor_cal.IsChecked()),
        )

    def update_adjustment_controls(self) -> None:
        """Update the adjustment controls based on the current configuration."""
        update_cal = getcfg("calibration.update")
        auto = self.get_measurement_mode() == "auto"
        do_cal = bool(
            getcfg("calibration.interactive_display_adjustment") or getcfg("trc")
        )
        enable = not update_cal and not auto and do_cal
        for option in (
            "whitepoint.colortemp",
            "whitepoint.x",
            "whitepoint.y",
            "calibration.luminance",
            "calibration.black_luminance",
            "calibration.interactive_display_adjustment",
        ):
            backup = getcfg(f"{option}.backup", False)
            if auto and backup is None:
                # Backup current settings
                setcfg(f"{option}.backup", getcfg(option, False))
            elif not auto and backup is not None:
                setcfg(option, getcfg(f"{option}.backup"))
                setcfg(f"{option}.backup", None)
        if auto or not do_cal:
            setcfg("whitepoint.colortemp", None)
            setcfg("whitepoint.x", None)
            setcfg("whitepoint.y", None)
            setcfg("3dlut.whitepoint.x", None)
            setcfg("3dlut.whitepoint.y", None)
            self.whitepoint_colortemp_textctrl.Hide()
            self.whitepoint_colortemp_label.Hide()
            self.whitepoint_x_textctrl.Hide()
            self.whitepoint_x_label.Hide()
            self.whitepoint_y_textctrl.Hide()
            self.whitepoint_y_label.Hide()
            self.visual_whitepoint_editor_btn.Hide()
            self.whitepoint_measure_btn.Hide()
            self.luminance_ctrl.SetSelection(0)
            self.luminance_textctrl.Hide()
            self.luminance_textctrl_label.Hide()
            setcfg("calibration.luminance", None)
            self.black_luminance_textctrl.Hide()
            self.black_luminance_textctrl_label.Hide()
            setcfg("calibration.black_luminance", None)
            setcfg("calibration.interactive_display_adjustment", 0)
        self.whitepoint_ctrl.Enable(enable)
        self.luminance_ctrl.Enable(enable)
        self.black_luminance_ctrl.Enable(enable)
        self.interactive_display_adjustment_cb.Enable(not update_cal and not auto)

        self.interactive_display_adjustment_cb.SetValue(
            not update_cal
            and bool(int(getcfg("calibration.interactive_display_adjustment")))
        )
        self.whitepoint_colortemp_textctrl.SetValue(
            str(stripzeros(getcfg("whitepoint.colortemp")))
        )
        self.whitepoint_x_textctrl.SetValue(round(getcfg("whitepoint.x"), 4))
        self.whitepoint_y_textctrl.SetValue(round(getcfg("whitepoint.y"), 4))
        sel = self.whitepoint_ctrl.GetSelection()
        if getcfg("whitepoint.colortemp", False):
            self.whitepoint_ctrl.SetSelection(1)
        elif getcfg("whitepoint.x", False) and getcfg("whitepoint.y", False):
            self.whitepoint_ctrl.SetSelection(2)
        else:
            self.whitepoint_ctrl.SetSelection(0)
        self.whitepoint_ctrl_handler(
            CustomEvent(wx.EVT_CHOICE.evtType[0], self.whitepoint_ctrl),
            -1 < sel != self.whitepoint_ctrl.GetSelection(),
        )
        show_advanced_options = bool(getcfg("show_advanced_options"))
        for ctrl in (
            self.whitepoint_colortemp_locus_label,
            self.whitepoint_colortemp_locus_ctrl,
        ):
            ctrl.Show(
                self.whitepoint_ctrl.GetSelection() in (0, 1)
                and not auto
                and do_cal
                and show_advanced_options
            )

        for name in ("luminance", "black_luminance"):
            userconf = bool(getcfg(f"calibration.{name}", False))
            getattr(self, f"{name}_ctrl").SetSelection(int(userconf))
            getattr(self, f"{name}_textctrl").SetValue(getcfg(f"calibration.{name}"))
            if name == "black_luminance":
                userconf = show_advanced_options and userconf
            else:
                self.ambient_luminance_measure_btn.Show(userconf)
            getattr(self, f"{name}_textctrl").Show(userconf)
            getattr(self, f"{name}_textctrl_label").Show(userconf)
            getattr(self, f"{name}_measure_btn").Show(userconf)

    def enable_3dlut_tab_handler(self, event: wx.Event) -> None:
        """Handle enabling or disabling the 3D LUT tab.

        Args:
            event (wx.Event): The event triggered by the menu item.
        """
        setcfg("3dlut.tab.enable", int(self.menuitem_enable_3dlut_tab.IsChecked()))
        setcfg("3dlut.tab.enable.backup", getcfg("3dlut.tab.enable"))
        if not getcfg("3dlut.tab.enable"):
            setcfg("3dlut.create", 0)
            self.lut3d_update_controls()
        self.update_main_controls()

    def enable_argyll_debug_handler(self, event: wx.Event) -> None:
        """Handle enabling or disabling Argyll debug mode.

        Args:
            event (wx.Event): The event triggered by the debug menu item.
        """
        if not getcfg("argyll.debug"):
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("argyll.debug.warning1"),
                ok=lang.getstr("ok"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-warning"),
                log=False,
            )
            result = dlg.ShowModal()
            dlg.Destroy()
            if result != wx.ID_OK:
                self.menuitem_enable_argyll_debug.Check(False)
                return
            InfoDialog(
                self,
                msg=lang.getstr("argyll.debug.warning2"),
                bitmap=get_icon(32, "dialog-warning"),
                log=False,
            )
        setcfg("argyll.debug", int(self.menuitem_enable_argyll_debug.IsChecked()))

    def enable_dry_run_handler(self, event: wx.Event) -> None:
        """Handle enabling or disabling dry run mode.

        Args:
            event (wx.Event): The event triggered by the dry run menu item.
        """
        setcfg("dry_run", int(self.menuitem_enable_dry_run.IsChecked()))
        self.menuitem_enable_argyll_debug.Enable(
            not self.menuitem_enable_dry_run.IsChecked()
        )

    def enable_menus(self, enable: bool = True) -> None:
        """Enable or disable all menus in the menubar.

        Args:
            enable (bool): If True, enable all menus; if False, disable them.
        """
        for menu, _label in self.menubar.GetMenus():
            for item in menu.GetMenuItems():
                item.Enable(enable)
        if enable:
            self.update_menus()

    def lut3d_check_bpc(self) -> None:
        """Check if black point compensation is enabled for 3D LUTs."""
        if getcfg("3dlut.create") and getcfg("profile.black_point_compensation"):
            # Warn about BPC if creating 3D LUT
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("black_point_compensation.3dlut.warning"),
                ok=lang.getstr("turn_off"),
                cancel=lang.getstr("setting.keep_current"),
                bitmap=get_icon(32, "dialog-warning"),
            )
            if dlg.ShowModal() == wx.ID_OK:
                setcfg("profile.black_point_compensation", 0)
                self.update_bpc()

    def check_3dlut_relcol_rendering_intent(self) -> None:
        """Check if relative colorimetric rendering intent is set for 3D LUTs."""
        if getcfg("3dlut.tab.enable") and getcfg("3dlut.rendering_intent") in (
            "a",
            "aa",
            "aw",
            "pa",
        ):
            wx.CallAfter(self.lut3d_confirm_relcol_rendering_intent)

    def lut3d_confirm_relcol_rendering_intent(self) -> None:
        """Confirm the use of relative colorimetric rendering intent for 3D LUTs."""
        dlg = ConfirmDialog(
            self,
            msg=lang.getstr("3dlut.confirm_relcol_rendering_intent"),
            ok=lang.getstr("yes"),
            cancel=lang.getstr("no"),
            bitmap=get_icon(32, "dialog-warning"),
        )
        result = dlg.ShowModal()
        dlg.Destroy()
        if result == wx.ID_OK:
            self.lut3d_set_option("3dlut.rendering_intent", "r")
            self.lut3d_rendering_intent_ctrl.SetSelection(
                self.rendering_intents_ba[getcfg("3dlut.rendering_intent")]
            )

    def lut3d_create_cb_handler(self, event: wx.Event) -> None:
        """Handle changes to the 3D LUT creation checkbox.

        Args:
            event (wx.Event): The event triggered by the checkbox.
        """
        v = int(self.lut3d_create_cb.GetValue())
        if v != getcfg("3dlut.create"):
            self.profile_settings_changed()
        setcfg("3dlut.create", v)
        self.calpanel.Freeze()
        self.lut3d_show_trc_controls()
        self.lut3d_update_apply_cal_control()
        self.lut3d_update_b2a_controls()
        self.calpanel.Thaw()
        self.lut3d_check_bpc()
        self.update_main_controls()

    def lut3d_init_input_profiles(self) -> None:
        """Initialize the input profiles for 3D LUTs."""
        self.input_profiles = {}
        for profile_filename in [
            "ACES.icm",
            "ACEScg.icm",
            "DCDM X'Y'Z'.icm",
            "Rec709.icm",
            "Rec2020.icm",
            "EBU3213_PAL.icm",
            "SMPTE_RP145_NTSC.icm",
            "SMPTE431_P3.icm",
            "SMPTE431_P3_D65.icm",
            getcfg("3dlut.input.profile"),
        ]:
            if not os.path.isabs(profile_filename):
                profile_filename = get_data_path("ref/" + profile_filename)
            if not profile_filename:
                continue
            try:
                profile = ICCProfile(profile_filename)
            except (OSError, ICCProfileInvalidError) as exception:
                print(f"{profile_filename}:", exception)
                continue
            if profile_filename not in list(self.input_profiles.values()):
                desc = profile.getDescription()
                desc = re.sub(
                    r"\s*(?:color profile|primaries with "
                    r"\S+ transfer function)$",
                    "",
                    desc,
                )
                self.input_profiles[desc] = profile_filename
        self.input_profiles = dict_sort(self.input_profiles)
        self.lut3d_input_profile_ctrl.SetItems(list(self.input_profiles.keys()))

    def lut3d_input_colorspace_handler(self, event: wx.Event) -> None:
        """Handle changes to the input colorspace selection for 3D LUTs.

        Args:
            event (wx.Event): The event triggered by the input colorspace control.
        """
        if not event:
            self.lut3d_input_profile_ctrl.SetToolTipString(
                getcfg("3dlut.input.profile")
            )
            return
        self.lut3d_set_option(
            "3dlut.input.profile",
            self.input_profiles[self.lut3d_input_profile_ctrl.GetStringSelection()],
            event,
        )
        lut3d_input_profile = ICCProfile(getcfg("3dlut.input.profile"))
        if (
            lut3d_input_profile
            and "rTRC" in lut3d_input_profile.tags
            and "gTRC" in lut3d_input_profile.tags
            and "bTRC" in lut3d_input_profile.tags
            and lut3d_input_profile.tags.rTRC
            == lut3d_input_profile.tags.gTRC
            == lut3d_input_profile.tags.bTRC
            and isinstance(lut3d_input_profile.tags.rTRC, CurveType)
        ):
            tf = lut3d_input_profile.tags.rTRC.get_transfer_function(outoffset=1.0)
            # Set gamma to profile gamma if single gamma profile
            # Backup current gamma
            # Restore previous gamma if not single gamma
            # profile
            setcfg_cond(
                tf[0][0].startswith("Gamma"),
                "3dlut.trc_gamma",
                round(tf[0][1], 2),
                True,
            )
            self.lut3d_update_trc_controls()
            self.lut3d_show_trc_controls()
        if getattr(self, "lut3dframe", None):
            self.lut3dframe.update_controls()
        self.lut3d_input_profile_ctrl.SetToolTipString(getcfg("3dlut.input.profile"))

    def lut3d_set_path(
        self, path: None | str = None, set_mr_sim_profile: bool = True
    ) -> None:
        """Set the path for the 3D LUT and update related settings.

        Args:
            path (None | str, optional): The path to the 3D LUT file. If None,
                the current path is used.
            set_mr_sim_profile (bool, optional): Whether to set the simulation
                profile for measurement reports.
        """
        self.lut3d_path = self.worker.lut3d_get_filename(path)
        devlink = os.path.splitext(self.lut3d_path)[0] + PROFILE_EXT
        mr_option_changed = False
        if devlink != getcfg("measurement_report.devlink_profile"):
            setcfg("measurement_report.devlink_profile", devlink)
            mr_option_changed = True
        # Simulation profile for 3D LUT
        if (
            set_mr_sim_profile
            and getcfg("3dlut.tab.enable")
            and (
                getcfg("3dlut.trc").startswith("smpte2084")
                or getcfg("3dlut.trc") == "hlg"
                or getcfg("3dlut.whitepoint.x", False)
            )
        ):
            # Use 3D LUT input profile
            cfgvalue = getcfg("3dlut.input.profile")
            # Add 3D LUT parameters and use only filename
            # (file will be in profile dir)
            cfgfn, cfgext = os.path.splitext(os.path.basename(cfgvalue))
            lut3d_fn = self.worker.lut3d_get_filename(cfgfn, False, False)
            cfgvalue = os.path.join(os.path.dirname(self.lut3d_path), lut3d_fn + cfgext)
            if cfgvalue != getcfg(
                "measurement_report.simulation_profile"
            ) and os.path.isfile(cfgvalue):
                setcfg("measurement_report.simulation_profile", cfgvalue)
                mr_option_changed = True
        if mr_option_changed:
            self.mr_update_controls()

    def lut3d_show_controls(self) -> None:
        """Show or hide the 3D LUT controls based on the current configuration."""
        show = True  # bool(getcfg("3dlut.create"))
        self.lut3d_input_profile_label.Show(show)
        self.lut3d_input_profile_ctrl.Show(show)
        self.lut3d_show_trc_controls()
        self.lut3d_show_encoding_controls(show)
        self.lut3d_format_label.Show(show)
        self.lut3d_format_ctrl.Show(show)
        show_advanced_options = getcfg("show_advanced_options")
        for ctrl in (
            self.lut3d_apply_cal_cb,
            self.gamut_mapping_mode,
            self.gamut_mapping_inverse_a2b,
            self.gamut_mapping_b2a,
        ):
            ctrl.GetContainingSizer().Show(ctrl, show_advanced_options and show)
        for ctrl in (self.lut3d_size_label, self.lut3d_size_ctrl):
            ctrl.GetContainingSizer().Show(ctrl, show)

    def lut3d_update_apply_cal_control(self) -> None:
        """Update the apply calibration control based on the current configuration."""
        profile = not getcfg("3dlut.create") and get_current_profile(True)
        enable_apply_cal = bool(
            getcfg("3dlut.create")
            or (profile and isinstance(profile.tags.get("vcgt"), VideoCardGammaType))
        )
        self.lut3d_apply_cal_cb.SetValue(
            enable_apply_cal and bool(getcfg("3dlut.output.profile.apply_cal"))
        )
        self.lut3d_apply_cal_cb.Enable(enable_apply_cal)

    def lut3d_update_b2a_controls(self) -> None:
        """Update the B2A controls based on the current configuration."""
        # Allow using B2A instead of inverse A2B?
        if getcfg("3dlut.create"):
            allow_b2a_gamap = getcfg("profile.type") in ("l", "x", "X") and getcfg(
                "profile.b2a.hires"
            )
        else:
            profile = get_current_profile(True)
            allow_b2a_gamap = (
                profile
                and "B2A0" in profile.tags
                and isinstance(profile.tags.B2A0, LUT16Type)
                and profile.tags.B2A0.clut_grid_steps >= 17
            )
        self.gamut_mapping_b2a.Enable(bool(allow_b2a_gamap))
        if not allow_b2a_gamap:
            setcfg("3dlut.gamap.use_b2a", 0)
        self.gamut_mapping_inverse_a2b.SetValue(not getcfg("3dlut.gamap.use_b2a"))
        self.gamut_mapping_b2a.SetValue(bool(getcfg("3dlut.gamap.use_b2a")))

    def lut3d_update_controls(self) -> None:
        """Update the 3D LUT controls based on the current configuration."""
        self.lut3d_create_cb.SetValue(bool(getcfg("3dlut.create")))
        lut3d_input_profile = getcfg("3dlut.input.profile")
        if lut3d_input_profile not in list(self.input_profiles.values()):
            if not lut3d_input_profile or not os.path.isfile(lut3d_input_profile):
                lut3d_input_profile = DEFAULTS["3dlut.input.profile"]
                setcfg("3dlut.input.profile", lut3d_input_profile)
            else:
                try:
                    profile = ICCProfile(lut3d_input_profile)
                except (OSError, ICCProfileInvalidError) as exception:
                    print(f"{lut3d_input_profile}:", exception)
                else:
                    desc = profile.getDescription()
                    desc = re.sub(
                        r"\s*(?:color profile|primaries with "
                        r"\S+ transfer function)$",
                        "",
                        desc,
                    )
                    self.input_profiles[desc] = lut3d_input_profile
        if lut3d_input_profile in list(self.input_profiles.values()):
            self.lut3d_input_profile_ctrl.SetSelection(
                list(self.input_profiles.values()).index(lut3d_input_profile)
            )
            self.lut3d_input_colorspace_handler(None)
        self.lut3d_update_apply_cal_control()
        self.lut3d_update_b2a_controls()
        self.lut3d_update_shared_controls()
        self.lut3d_update_encoding_controls()
        self.lut3d_show_controls()

    def profile_quality_warning_handler(self, event: wx.Event) -> None:
        """Show a warning dialog if the profile quality is set to ultra.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        q = self.get_profile_quality()
        if q == "u":
            InfoDialog(
                self,
                msg=lang.getstr("quality.ultra.warning"),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-warning"),
                log=False,
            )

    def profile_quality_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the profile quality controls.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        debug_print(
            "[D] profile_quality_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        oldq = getcfg("profile.quality")
        q = self.get_profile_quality()
        if q == oldq:
            return
        if q == "l":
            self.profile_quality_info.SetLabel(lang.getstr("calibration.quality.low"))
        elif q == "m":
            self.profile_quality_info.SetLabel(
                lang.getstr("calibration.quality.medium")
            )
        elif q == "h":
            self.profile_quality_info.SetLabel(lang.getstr("calibration.quality.high"))
        elif q == "u":
            self.profile_quality_info.SetLabel(lang.getstr("calibration.quality.ultra"))
        self.profile_settings_changed()
        setcfg("profile.quality", q)
        self.update_profile_name()
        self.set_default_testchart(False)
        wx.CallAfter(self.check_testchart_patches_amount)

    def calibration_file_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the calibration file control.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        debug_print(
            "[D] calibration_file_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        sel = self.calibration_file_ctrl.GetSelection()
        if sel > 0:
            self.load_cal_handler(None, path=self.recent_cals[sel])
            return

        self.cal_changed(setchanged=False)
        if getattr(self, "lut3dframe", None):
            self.lut3dframe.set_profile("output")
        if getattr(self, "reportframe", None):
            self.reportframe.set_profile("output")
        # # Set measurement report dest profile to current
        # setcfg("measurement_report.output_profile",
        # get_current_profile_path())
        if self.lut3d_settings_panel.IsShown() or self.mr_settings_panel.IsShown():
            if self.mr_settings_panel.IsShown():
                self.mr_update_controls()
            else:
                self.set_profile("output")
            if self.lut3d_settings_panel.IsShown():
                self.lut3d_show_trc_controls()
            self.update_main_controls()

    def settings_discard_changes(
        self, sel: None | int = None, keep_changed_state: bool = False
    ) -> None:
        """Update the calibration file control.

        Also remove the leading asterisk (*) from items.

        Args:
            sel (None | int, optional): The index of the selected item in the
                calibration file control. If None, the current selection will
                be used.
            keep_changed_state (bool, optional): If True, the changed state
                will not be reset. Defaults to False.
        """
        if sel is None:
            sel = self.calibration_file_ctrl.GetSelection()
        if not keep_changed_state:
            setcfg("settings.changed", 0)
        items = self.calibration_file_ctrl.GetItems()
        changed = False
        for j, item in enumerate(items):
            # if j != sel and item[0] == "*":
            if item[0] == "*":
                items[j] = item[2:]
                changed = True
        if changed:
            self.calibration_file_ctrl.Freeze()
            self.calibration_file_ctrl.SetItems(items)
            self.calibration_file_ctrl.SetSelection(sel)
            self.calibration_file_ctrl.Thaw()

    def settings_confirm_discard(self) -> bool:
        """Show a dialog for user to confirm or cancel discarding changed settings.

        Returns:
            bool: True if the user confirmed discarding changes, False otherwise.
        """
        sel = self.calibration_file_ctrl.GetSelection()
        cal = getcfg("calibration.file", False) or ""
        if cal not in self.recent_cals:
            self.recent_cals.append(cal)
        # The case-sensitive index could fail because of
        # case insensitive file systems, e.g. if the
        # stored filename string is
        # "C:\Users\Name\AppData\DisplayCAL\storage\MyFile"
        # but the actual filename is
        # "C:\Users\Name\AppData\DisplayCAL\storage\myfile"
        # (maybe because the user renamed the file)
        idx = index_fallback_ignorecase(self.recent_cals, cal)
        self.calibration_file_ctrl.SetSelection(idx)
        dlg = ConfirmDialog(
            self,
            msg=lang.getstr("warning.discard_changes"),
            ok=lang.getstr("ok"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, "dialog-warning"),
        )
        result = dlg.ShowModal()
        dlg.Destroy()
        if result != wx.ID_OK:
            return False
        self.settings_discard_changes(sel)
        return True

    def calibration_quality_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the calibration quality controls.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        debug_print(
            "[D] calibration_quality_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        q = self.get_calibration_quality()
        self.set_calibration_quality_label(q)
        if q != getcfg("calibration.quality"):
            self.profile_settings_changed()
        setcfg("calibration.quality", q)
        self.update_estimated_measurement_time("cal")
        self.update_profile_name()

    def set_calibration_quality_label(self, q: str) -> None:
        """Set the label for the calibration quality info based on the quality level.

        Args:
            q (str): The quality level, which can be one of "v", "l", "m", "h",
                or "u".
        """
        if q == "v":
            self.calibration_quality_info.SetLabel(
                lang.getstr("calibration.speed.veryhigh")
            )
        elif q == "l":
            self.calibration_quality_info.SetLabel(
                lang.getstr("calibration.speed.high")
            )
        elif q == "m":
            self.calibration_quality_info.SetLabel(
                lang.getstr("calibration.speed.medium")
            )
        elif q == "h":
            self.calibration_quality_info.SetLabel(lang.getstr("calibration.speed.low"))
        elif q == "u":
            self.calibration_quality_info.SetLabel(
                lang.getstr("calibration.speed.verylow")
            )

    def interactive_display_adjustment_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the interactive display adjustment controls.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        debug_print(
            "[D] interactive_display_adjustment_ctrl_handler called "
            f"for ID {event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        v = int(self.interactive_display_adjustment_cb.GetValue())
        if v != getcfg("calibration.interactive_display_adjustment"):
            setcfg("calibration.interactive_display_adjustment", v)
            self.profile_settings_changed()
            self.panel.Freeze()
            self.update_adjustment_controls()
            self.calpanel.Layout()
            self.calpanel.Refresh()
            self.panel.Thaw()
            self.update_main_controls()
            self.update_profile_name()

    def black_point_compensation_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the black point compensation controls.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        v = int(self.black_point_compensation_cb.GetValue())
        if v != getcfg("profile.black_point_compensation"):
            self.profile_settings_changed()
        setcfg("profile.black_point_compensation", v)
        self.lut3d_check_bpc()

    def black_point_correction_auto_handler(
        self, event: None | wx.Event = None
    ) -> None:
        """Handle changes to the black point correction auto checkbox.

        Args:
            event (wx.Event, optional): The event that triggered the handler.
        """
        if event:
            auto = self.black_point_correction_auto_cb.GetValue()
            setcfg("calibration.black_point_correction.auto", int(auto))
            self.cal_changed()
            self.update_profile_name()
        else:
            auto = getcfg("calibration.black_point_correction.auto")
            self.black_point_correction_auto_cb.SetValue(bool(auto))
        show = (
            self.trc_ctrl.GetSelection() > 0
            and bool(getcfg("show_advanced_options"))
            and not auto
        )
        self.calpanel.Freeze()
        self.black_point_correction_ctrl.Show(show)
        self.black_point_correction_intctrl.Show(show)
        self.black_point_correction_intctrl_label.Show(show)
        self.calpanel.Layout()
        self.calpanel.Refresh()
        self.calpanel.Thaw()

    def black_point_correction_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the black point correction controls.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        debug_print(
            "[D] black_point_correction_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        if event.GetId() == self.black_point_correction_intctrl.GetId():
            self.black_point_correction_ctrl.SetValue(
                self.black_point_correction_intctrl.GetValue()
            )
        else:
            self.black_point_correction_intctrl.SetValue(
                self.black_point_correction_ctrl.GetValue()
            )
        v = self.get_black_point_correction()
        if float(v) != getcfg("calibration.black_point_correction"):
            self.cal_changed()
        setcfg("calibration.black_point_correction", v)
        self.black_point_rate_ctrl.Enable(
            getcfg("calibration.black_point_correction") < 1
            and DEFAULTS["calibration.black_point_rate.enabled"]
        )
        self.black_point_rate_floatctrl.Enable(
            getcfg("calibration.black_point_correction") < 1
            and DEFAULTS["calibration.black_point_rate.enabled"]
        )
        self.update_profile_name()

    def black_point_rate_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the black point rate controls.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        debug_print(
            "[D] black_point_rate_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        if event.GetId() == self.black_point_rate_floatctrl.GetId():
            self.black_point_rate_ctrl.SetValue(
                round(self.black_point_rate_floatctrl.GetValue() * 100)
            )
        else:
            self.black_point_rate_floatctrl.SetValue(
                self.black_point_rate_ctrl.GetValue() / 100.0
            )
        v = self.get_black_point_rate()
        if v != str(getcfg("calibration.black_point_rate")):
            self.cal_changed()
        setcfg("calibration.black_point_rate", v)
        self.update_profile_name()

    def black_output_offset_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the black output offset controls.

        Args:
            event (wx.Event): The event that triggered the handler.
        """
        debug_print(
            "[D] black_output_offset_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        if event.GetId() == self.black_output_offset_intctrl.GetId():
            self.black_output_offset_ctrl.SetValue(
                self.black_output_offset_intctrl.GetValue()
            )
        else:
            self.black_output_offset_intctrl.SetValue(
                self.black_output_offset_ctrl.GetValue()
            )
        v = self.get_black_output_offset()
        if float(v) != getcfg("calibration.black_output_offset"):
            self.cal_changed()
            setcfg("calibration.black_output_offset", v)
            self.update_profile_name()
            self.update_trc_control()
            # self.show_trc_controls(True)

    def visual_whitepoint_editor_handler(self, event: wx.Event) -> None:
        """Open the visual whitepoint editor.

        Args:
            event (wx.Event): The event that triggered the editor opening.
        """
        if not self.setup_patterngenerator(self):
            return
        display_name = config.get_display_name(None, True)
        if display_name == "madVR":
            # Disable gamma ramp
            self.worker.madtpg.set_device_gamma_ramp(None)
            # Disable 3D LUT
            self.worker.madtpg.disable_3dlut()
            if self.worker.madtpg.is_fullscreen():
                # Leave fullscreen
                self.worker.madtpg.leave_fullscreen()
        elif display_name == "Prisma":
            # Disable 3D LUT
            try:
                self.worker.patterngenerator.disable_processing()
            except OSError as exception:
                show_result_dialog(exception)
                return
        pos = self.GetDisplay().ClientArea[:2]
        geometry = None
        profile = None
        print(" *************** display_name: ", display_name)
        if display_name in (
            "madVR",
            "Prisma",
            "Resolve",
            "Web @ localhost",
        ) or display_name.startswith("Chromecast "):
            patterngenerator = self.worker.patterngenerator
        else:
            patterngenerator = None
            display_no = config.get_display_number(getcfg("display.number") - 1)
            try:
                display = wx.Display(display_no)
            except Exception as exception:
                print(f"wx.Display({display_no}):", exception)
            else:
                pos = display.ClientArea[:2]
                profile = config.get_current_profile(True)
                if profile and profile.filename in self.presets:
                    profile = None
                else:
                    geometry = display.Geometry.Get()  # Has to be tuple!
        display_name = display_name.replace("[PRIMARY]", lang.getstr("display.primary"))
        title = f"{display_name} - {lang.getstr('whitepoint.visual_editor')}"
        self.wpeditor = VisualWhitepointEditor(
            self,
            pos=pos,
            title=title,
            patterngenerator=patterngenerator,
            geometry=geometry,
            profile=profile,
        )
        if (
            patterngenerator
            and ChromeCastPatternGenerator
            and isinstance(patterngenerator, ChromeCastPatternGenerator)
        ):
            self.wpeditor.Bind(wx.EVT_CLOSE, self.patterngenerator_disconnect)
        self.wpeditor.RealCenterOnScreen()
        self.wpeditor.Show()
        self.wpeditor.Raise()

    def patterngenerator_disconnect(self, event: wx.Event) -> None:
        """Disconnect the pattern generator client.

        Args:
            event (wx.Event): The event that triggered the disconnection.
        """
        try:
            self.worker.patterngenerator.disconnect_client()
        except Exception as exception:
            print(exception)
        event.Skip()

    def luminance_measure_handler(self, event: wx.Event) -> None:
        """Start measuring luminance.

        Args:
            event (wx.Event): The event that triggered the luminance measurement.
        """
        if not self.setup_patterngenerator(self):
            return
        evtobjname = event.GetEventObject().Name
        color = wx.WHITE if evtobjname == "luminance_measure_btn" else wx.BLACK
        if self.worker.patterngenerator:
            self.worker.patterngenerator.send(
                tuple(v / 255.0 for v in color[:3]),
                (0, 0, 0),
                x=0.25,
                y=0.25,
                w=0.5,
                h=0.5,
            )
        frame = wx.Frame(
            self,
            title=lang.getstr("measureframe.title"),
            style=wx.DEFAULT_FRAME_STYLE
            | wx.FRAME_TOOL_WINDOW
            | wx.FRAME_FLOAT_ON_PARENT,
        )
        frame.SetIcons(config.get_icon_bundle([256, 48, 32, 16], APPNAME))
        panel = wx.Panel(frame, size=(int(get_default_size()),) * 2)
        panel.SetBackgroundColour(color)
        btncls = ThemedGenButton if wx.Platform == "__WXMSW__" else wx.Button
        measure_btn = btncls(panel, label=lang.getstr("measure"), name=evtobjname)
        measure_btn.Bind(wx.EVT_BUTTON, self.ambient_measure_handler)
        panel.Sizer = wx.FlexGridSizer(2, 3)
        panel.Sizer.Add((1, 1))
        panel.Sizer.Add((1, 1))
        panel.Sizer.Add((1, 1))
        panel.Sizer.AddGrowableRow(0)
        panel.Sizer.Add((1, 1))
        panel.Sizer.Add(measure_btn, flag=wx.ALL | wx.ALIGN_CENTER, border=12)
        panel.Sizer.Add((1, 1))
        panel.Sizer.AddGrowableCol(0)
        panel.Sizer.AddGrowableCol(2)
        frame.Sizer = wx.BoxSizer(wx.VERTICAL)
        frame.Sizer.Add(panel, 1, flag=wx.EXPAND)
        frame.Sizer.SetSizeHints(frame)
        frame.Sizer.Layout()
        if (
            self.worker.patterngenerator
            and ChromeCastPatternGenerator
            and isinstance(self.worker.patterngenerator, ChromeCastPatternGenerator)
        ):
            frame.Bind(wx.EVT_CLOSE, self.patterngenerator_disconnect)
        frame.Show()
        self.measureframes.append(frame)

    def ambient_measure_handler(self, event: wx.Event) -> None:
        """Start measuring ambient illumination.

        Args:
            event (wx.Event): The event that triggered the measurement.
        """
        if not check_set_argyll_bin():
            return
        # Minimum Windows version: XP or Server 2003
        if sys.platform == "win32" and sys.getwindowsversion() < (5, 1):
            show_result_dialog(Error(lang.getstr("windows.version.unsupported")))
            return
        print("-" * 80)
        self.stop_timers()
        evtobjname = event.GetEventObject().Name
        lstr = "measure"
        if evtobjname == "visual_whitepoint_editor_measure_btn":
            interactive_frame = event.GetEventObject().TopLevelParent
            while not isinstance(interactive_frame, VisualWhitepointEditor):
                # Floated panel
                interactive_frame = interactive_frame.Parent
        elif evtobjname in ("luminance_measure_btn", "black_luminance_measure_btn"):
            interactive_frame = "luminance"
        else:
            interactive_frame = "ambient"
            lstr = "ambient.measure"
        print(lang.getstr(lstr))
        self.worker.interactive = interactive_frame not in ("ambient", "luminance")
        self.worker.start(
            self.ambient_measure_consumer,
            self.ambient_measure_producer,
            ckwargs={"evtobjname": evtobjname},
            wkwargs={"interactive_frame": interactive_frame},
            progress_title=lang.getstr("ambient.measure"),
            interactive_frame=interactive_frame,
        )

    def ambient_measure_producer(
        self, interactive_frame: str | VisualWhitepointEditor
    ) -> str | Exception:
        """Process spotread output for ambient readings.

        Args:
            interactive_frame (str | VisualWhitepointEditor): The frame or mode
                in which the ambient measurement is being taken. If it is a
                `VisualWhitepointEditor`, it indicates that the measurement is
                being taken in an interactive whitepoint adjustment context.

        Returns:
            str | Exception: The result of the measurement command or an
                exception if an error occurs.
        """
        cmd = get_argyll_util("spotread")
        # Emissive if interactive_frame is not ambient or Ambient
        mode = "-e" if interactive_frame != "ambient" else "-a"
        args = ["-v", mode, "-x"]
        if getcfg("extra_args.spotread").strip():
            args += parse_argument_string(getcfg("extra_args.spotread"))
        result = self.worker.add_measurement_features(
            args, False, allow_nondefault_observer=True, ambient=mode == "-a"
        )
        if isinstance(result, Exception):
            return result
        return self.worker.exec_cmd(cmd, args, capture_output=True, skip_scripts=True)

    def ambient_measure_consumer(
        self, result: None | str | Exception = None, evtobjname: None | str = None
    ) -> None:
        """Process ambient measurement results.

        Args:
            result (None | str | Exception, optional): The result of the
                measurement command.
            evtobjname (None | str, optional): The name of the event object
                that triggered the measurement.
        """
        self.start_timers()
        if not result or isinstance(result, Exception):
            if getattr(self.worker, "subprocess", None):
                self.worker.quit_terminate_cmd()
            if isinstance(result, Exception):
                show_result_dialog(result, self)
            return
        result = re.sub(r"[^\t\n\r\x20-\x7f]", "", "".join(self.worker.output)).strip()
        if getcfg("whitepoint.colortemp.locus") == "T":
            K = re.search(r"Planckian temperature += (\d+(?:\.\d+)?)K", result, re.I)  # noqa: N806
        else:
            K = re.search(r"Daylight temperature += (\d+(?:\.\d+)?)K", result, re.I)  # noqa: N806
        XYZ = re.search(r"XYZ: (\d+(?:\.\d+)) (\d+(?:\.\d+)) (\d+(?:\.\d+))", result)  # noqa: N806
        Yxy = re.search(r"Yxy: (\d+(?:\.\d+)) (\d+(?:\.\d+)) (\d+(?:\.\d+))", result)  # noqa: N806
        Y = re.search(  # noqa: N806
            r"Y: (\d+(?:\.\d+))", result
        )  # Monochrome, e.g. Spyder4/5
        lux = re.search(r"Ambient = (\d+(?:\.\d+)) Lux", result, re.I)
        if not result or (not K and not XYZ and not Yxy and not lux):
            show_result_dialog(Error(result + lang.getstr("failure")), self)
            return
        if K:
            K = float(K.groups()[0])  # noqa: N806
        print(lang.getstr("success"))
        set_whitepoint = evtobjname in (
            "visual_whitepoint_editor_measure_btn",
            "whitepoint_measure_btn",
        )
        set_ambient = evtobjname == "ambient_measure_btn"
        if (
            set_whitepoint
            and not set_ambient
            and lux
            and getcfg("show_advanced_options")
            and getcfg("trc", False) in ("709", "240")
        ):
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("ambient.set"),
                ok=lang.getstr("yes"),
                cancel=lang.getstr("no"),
                bitmap=get_icon(32, "dialog-question"),
            )
            set_ambient = dlg.ShowModal() == wx.ID_OK
            dlg.Destroy()
        if set_ambient:
            if lux:
                self.ambient_viewcond_adjust_textctrl.SetValue(float(lux.groups()[0]))
                self.ambient_viewcond_adjust_cb.SetValue(True)
                self.ambient_viewcond_adjust_ctrl_handler(
                    CustomEvent(
                        wx.EVT_CHECKBOX.evtType[0], self.ambient_viewcond_adjust_cb
                    )
                )
            else:
                show_result_dialog(
                    Error(lang.getstr("ambient.measure.light_level.missing")), self
                )
            if not set_whitepoint and K is not None and 4000 <= K <= 25000:
                dlg = ConfirmDialog(
                    self,
                    msg=lang.getstr("whitepoint.set"),
                    ok=lang.getstr("yes"),
                    cancel=lang.getstr("no"),
                    bitmap=get_icon(32, "dialog-question"),
                )
                set_whitepoint = dlg.ShowModal() == wx.ID_OK
                dlg.Destroy()
        elif XYZ or Y:
            # White or black luminance or Monochrome, e.g. Spyder4/5
            Y = XYZ.group(2) if XYZ else Y.group(1)  # noqa: N806
            Y = float(Y)  # noqa: N806
            if evtobjname in ("luminance_measure_btn", "ambient_luminance_measure_btn"):
                # Force minimum luminance of 40 cd/m2 which should be suitable for
                # dark viewing. See (e.g.) research done by Mantiuk et al,
                # "Display Considerations for Night and Low-Illumination Viewing"
                # https://www.cl.cam.ac.uk/~rkm38/pdfs/mantiuk09dcnliv.pdf
                Y = max(Y, 40)  # noqa: N806
                self.luminance_textctrl.SetValue(Y)
                self.luminance_ctrl_handler(
                    CustomEvent(wx.EVT_CHOICE.evtType[0], self.luminance_ctrl)
                )
            elif evtobjname == "black_luminance_measure_btn":
                self.black_luminance_textctrl.SetValue(Y)
                self.black_luminance_ctrl_handler(
                    CustomEvent(wx.EVT_CHOICE.evtType[0], self.black_luminance_ctrl)
                )
        if not set_whitepoint:
            return
        # Set whitepoint controls
        if evtobjname == "visual_whitepoint_editor_measure_btn" and XYZ:
            RGB = [  # noqa: N806
                getcfg("whitepoint.visual_editor.r"),
                getcfg("whitepoint.visual_editor.g"),
                getcfg("whitepoint.visual_editor.b"),
            ]
            if max(RGB) < 255:
                # Set luminance
                self.luminance_ctrl.SetSelection(1)
                self.luminance_textctrl.SetValue(float(XYZ.group(2)))
            else:
                self.luminance_ctrl.SetSelection(0)
            self.luminance_ctrl_handler(
                CustomEvent(wx.EVT_CHOICE.evtType[0], self.luminance_ctrl)
            )
        if not K and not Yxy:
            # Monochrome reading?
            show_result_dialog(
                Error(
                    lang.getstr(
                        "ambient.measure.color.unsupported",
                        self.comport_ctrl.GetStringSelection(),
                    )
                ),
                self,
            )
            return
        if K and self.whitepoint_ctrl.GetSelection() in (0, 1):
            self.whitepoint_ctrl.SetSelection(1)
            self.whitepoint_colortemp_textctrl.SetValue(str(K))
        elif Yxy:
            self.whitepoint_ctrl.SetSelection(2)
            Y, x, y = Yxy.groups()  # noqa: N806
            self.whitepoint_x_textctrl.SetValue(round(float(x), 4))
            self.whitepoint_y_textctrl.SetValue(round(float(y), 4))
        self.whitepoint_ctrl_handler(
            CustomEvent(wx.EVT_CHOICE.evtType[0], self.whitepoint_ctrl)
        )

    def ambient_viewcond_adjust_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the ambient view condition adjustment control.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if event.GetId() == self.ambient_viewcond_adjust_textctrl.GetId() and (
            not self.ambient_viewcond_adjust_cb.GetValue()
            or getcfg("calibration.ambient_viewcond_adjust.lux")
            == self.ambient_viewcond_adjust_textctrl.GetValue()
        ):
            event.Skip()
            return
        debug_print(
            "[D] ambient_viewcond_adjust_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        if event.GetId() == self.ambient_viewcond_adjust_textctrl.GetId():
            if self.ambient_viewcond_adjust_textctrl.GetValue():
                self.ambient_viewcond_adjust_cb.SetValue(True)
            else:
                self.ambient_viewcond_adjust_cb.SetValue(False)
        if self.ambient_viewcond_adjust_cb.GetValue():
            self.ambient_viewcond_adjust_textctrl.Enable()
            v = self.ambient_viewcond_adjust_textctrl.GetValue()
            if v and (v < 0.000001 or v > sys.maxsize):
                wx.Bell()
                self.ambient_viewcond_adjust_textctrl.SetValue(
                    getcfg("calibration.ambient_viewcond_adjust.lux")
                )
            if event.GetId() == self.ambient_viewcond_adjust_cb.GetId():
                self.ambient_viewcond_adjust_textctrl.SetFocus()
        else:
            self.ambient_viewcond_adjust_textctrl.Disable()
        v1 = int(self.ambient_viewcond_adjust_cb.GetValue())
        v2 = self.ambient_viewcond_adjust_textctrl.GetValue()
        if v1 != getcfg("calibration.ambient_viewcond_adjust") or v2 != getcfg(
            "calibration.ambient_viewcond_adjust.lux", False
        ):
            self.cal_changed()
        setcfg("calibration.ambient_viewcond_adjust", v1)
        setcfg("calibration.ambient_viewcond_adjust.lux", v2)
        self.update_profile_name()
        if event.GetEventType() == wx.EVT_KILL_FOCUS.evtType[0]:
            event.Skip()

    def ambient_viewcond_adjust_info_handler(self, event: wx.Event) -> None:
        """Show information dialog about ambient view condition adjustment.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        InfoDialog(
            self,
            msg=lang.getstr("calibration.ambient_viewcond_adjust.info"),
            ok=lang.getstr("ok"),
            bitmap=get_icon(32, "dialog-information"),
            log=False,
        )

    def black_luminance_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the black luminance control.

        Args:
            event (wx.Event): The event that triggered this handler.

        Raises:
            ValueError: If the input value for black luminance is out of range.
        """
        if event.GetId() == self.black_luminance_textctrl.GetId() and (
            self.black_luminance_ctrl.GetSelection() != 1
            or getcfg("calibration.black_luminance")
            == self.black_luminance_textctrl.GetValue()
            or not self.black_luminance_ctrl.IsShown()
        ):
            event.Skip()
            return
        debug_print(
            "[D] black_luminance_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        self.calpanel.Freeze()
        if self.black_luminance_ctrl.GetSelection() == 1:  # cd/m2
            self.black_luminance_textctrl.Show()
            self.black_luminance_textctrl_label.Show()
            self.black_luminance_measure_btn.Show()
            try:
                v = self.black_luminance_textctrl.GetValue()
                if v < 0.000001 or v > 100000:
                    raise ValueError
            except ValueError:
                wx.Bell()
                self.black_luminance_textctrl.SetValue(
                    getcfg("calibration.black_luminance")
                )
            if (
                event.GetId() == self.black_luminance_ctrl.GetId()
                and self.black_luminance_ctrl.GetSelection() == 1
            ):
                self.black_luminance_textctrl.SetFocus()
        else:
            self.black_luminance_textctrl.Hide()
            self.black_luminance_textctrl_label.Hide()
            self.black_luminance_measure_btn.Hide()
        self.calpanel.Layout()
        self.calpanel.Refresh()
        self.calpanel.Thaw()
        v = self.get_black_luminance()
        if v != str(getcfg("calibration.black_luminance", False)):
            self.cal_changed()
        setcfg("calibration.black_luminance", v)
        self.update_profile_name()
        if event.GetEventType() == wx.EVT_KILL_FOCUS.evtType[0]:
            event.Skip()

    def luminance_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the luminance control.

        Args:
            event (wx.Event): The event that triggered this handler.

        Raises:
            ValueError: If the input value for luminance is out of range.
        """
        if event.GetId() == self.luminance_textctrl.GetId() and (
            self.luminance_ctrl.GetSelection() != 1
            or getcfg("calibration.luminance") == self.luminance_textctrl.GetValue()
        ):
            event.Skip()
            return
        debug_print(
            "[D] luminance_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        self.calpanel.Freeze()
        if self.luminance_ctrl.GetSelection() == 1:  # cd/m2
            self.luminance_textctrl.Show()
            self.luminance_textctrl_label.Show()
            self.luminance_measure_btn.Show()
            self.ambient_luminance_measure_btn.Show()
            try:
                v = self.luminance_textctrl.GetValue()
                if v < 0.000001 or v > 100000:
                    raise ValueError
            except ValueError:
                wx.Bell()
                self.luminance_textctrl.SetValue(getcfg("calibration.luminance"))
            if (
                event.GetId() == self.luminance_ctrl.GetId()
                and self.luminance_ctrl.GetSelection() == 1
            ):
                self.luminance_textctrl.SetFocus()
        else:
            self.luminance_textctrl.Hide()
            self.luminance_textctrl_label.Hide()
            self.luminance_measure_btn.Hide()
            self.ambient_luminance_measure_btn.Hide()
        self.calpanel.Layout()
        self.calpanel.Refresh()
        self.calpanel.Thaw()
        v = self.get_luminance()
        if v != str(getcfg("calibration.luminance", False)):
            self.cal_changed()
        setcfg("calibration.luminance", v)
        self.update_profile_name()
        if event.GetEventType() == wx.EVT_KILL_FOCUS.evtType[0]:
            event.Skip()

    def whitepoint_colortemp_locus_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the whitepoint color temperature locus control.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        debug_print(
            "[D] whitepoint_colortemp_locus_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        v = self.get_whitepoint_locus()
        if v != getcfg("whitepoint.colortemp.locus"):
            setcfg("whitepoint.colortemp.locus", v)
            self.whitepoint_ctrl_handler(
                CustomEvent(wx.EVT_CHOICE.evtType[0], self.whitepoint_ctrl), False
            )
            self.profile_settings_changed()
        self.update_profile_name()

    def whitepoint_ctrl_handler(
        self, event: wx.Event, cal_changed: None | bool = None
    ) -> None:
        """Handle changes to the whitepoint control.

        Args:
            event (wx.Event): The event that triggered this handler.
            cal_changed (None | bool, optional): If set, indicates whether the
                calibration has changed.

        Raises:
            ValueError: If the input values for color temperature or chromaticity
                coordinates are out of range.
        """
        if event.GetId() == self.whitepoint_colortemp_textctrl.GetId() and (
            self.whitepoint_ctrl.GetSelection() != 1
            or str(int(getcfg("whitepoint.colortemp")))
            == self.whitepoint_colortemp_textctrl.GetValue()
        ):
            event.Skip()
            return
        if event.GetId() == self.whitepoint_x_textctrl.GetId() and (
            self.whitepoint_ctrl.GetSelection() != 2
            or round(getcfg("whitepoint.x"), 4)
            == round(self.whitepoint_x_textctrl.GetValue(), 4)
        ):
            event.Skip()
            return
        if event.GetId() == self.whitepoint_y_textctrl.GetId() and (
            self.whitepoint_ctrl.GetSelection() != 2
            or round(getcfg("whitepoint.y"), 4)
            == round(self.whitepoint_y_textctrl.GetValue(), 4)
        ):
            event.Skip()
            return
        if (
            event.GetEventObject()
            and hasattr(event.GetEventObject(), "IsShown")
            and not event.GetEventObject().IsShown()
        ):
            event.Skip()
            return
        debug_print(
            "[D] whitepoint_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        self.calpanel.Freeze()
        show_advanced_options = bool(getcfg("show_advanced_options"))
        if self.whitepoint_ctrl.GetSelection() == 2:
            # x,y chromaticity coordinates
            self.whitepoint_colortemp_locus_label.Hide()
            self.whitepoint_colortemp_locus_ctrl.Hide()
            self.whitepoint_colortemp_textctrl.Hide()
            self.whitepoint_colortemp_label.Hide()
            self.whitepoint_x_textctrl.Show()
            self.whitepoint_x_label.Show()
            self.whitepoint_y_textctrl.Show()
            self.whitepoint_y_label.Show()
            try:
                v = self.whitepoint_x_textctrl.GetValue()
                if v < 0 or v > 1:
                    raise ValueError
            except ValueError:
                wx.Bell()
                self.whitepoint_x_textctrl.SetValue(round(getcfg("whitepoint.x"), 4))
            try:
                v = self.whitepoint_y_textctrl.GetValue()
                if v < 0 or v > 1:
                    raise ValueError
            except ValueError:
                wx.Bell()
                self.whitepoint_y_textctrl.SetValue(round(getcfg("whitepoint.y"), 4))
            x = self.whitepoint_x_textctrl.GetValue()
            y = self.whitepoint_y_textctrl.GetValue()
            k = xyY2CCT(x, y, 1.0)
            if k:
                self.whitepoint_colortemp_textctrl.SetValue(
                    str(stripzeros(math.ceil(k)))
                )
            else:
                self.whitepoint_colortemp_textctrl.SetValue("")
            if cal_changed is None and (
                not getcfg("whitepoint.colortemp", False)
                and x == getcfg("whitepoint.x")
                and y == getcfg("whitepoint.y")
            ):
                cal_changed = False
            setcfg("whitepoint.colortemp", None)
            setcfg("whitepoint.x", x)
            setcfg("whitepoint.y", y)
            setcfg("3dlut.whitepoint.x", x)
            setcfg("3dlut.whitepoint.y", y)
            if (
                event.GetId() == self.whitepoint_ctrl.GetId()
                and self.whitepoint_ctrl.GetSelection() == 2
                and not self.updatingctrls
            ):
                self.whitepoint_x_textctrl.SetFocus()
        elif self.whitepoint_ctrl.GetSelection() == 1:
            # Color temperature
            self.whitepoint_colortemp_locus_label.Show(show_advanced_options)
            self.whitepoint_colortemp_locus_ctrl.Show(show_advanced_options)
            self.whitepoint_colortemp_textctrl.Show()
            self.whitepoint_colortemp_label.Show()
            self.whitepoint_x_textctrl.Hide()
            self.whitepoint_x_label.Hide()
            self.whitepoint_y_textctrl.Hide()
            self.whitepoint_y_label.Hide()
            try:
                v = float(
                    self.whitepoint_colortemp_textctrl.GetValue().replace(",", ".")
                )
                if v < 1000 or v > 15000:
                    raise ValueError
                self.whitepoint_colortemp_textctrl.SetValue(str(stripzeros(v)))
            except ValueError:
                wx.Bell()
                self.whitepoint_colortemp_textctrl.SetValue(
                    str(stripzeros(getcfg("whitepoint.colortemp")))
                )
            v = float(self.whitepoint_colortemp_textctrl.GetValue())
            if cal_changed is None and (
                getcfg("whitepoint.colortemp") == v
                and not getcfg("whitepoint.x", False)
                and not getcfg("whitepoint.y", False)
            ):
                cal_changed = False
            setcfg("whitepoint.colortemp", int(v))
            setcfg("whitepoint.x", None)
            setcfg("whitepoint.y", None)
            if (
                event.GetId() == self.whitepoint_ctrl.GetId()
                and self.whitepoint_ctrl.GetSelection() == 1
                and not self.updatingctrls
            ):
                self.whitepoint_colortemp_textctrl.SetFocus()
                self.whitepoint_colortemp_textctrl.SelectAll()
        else:
            # "As measured"
            self.whitepoint_colortemp_locus_label.Show(show_advanced_options)
            self.whitepoint_colortemp_locus_ctrl.Show(show_advanced_options)
            self.whitepoint_colortemp_textctrl.Hide()
            self.whitepoint_colortemp_label.Hide()
            self.whitepoint_x_textctrl.Hide()
            self.whitepoint_x_label.Hide()
            self.whitepoint_y_textctrl.Hide()
            self.whitepoint_y_label.Hide()
            if (
                cal_changed is None
                and not getcfg("whitepoint.colortemp", False)
                and not getcfg("whitepoint.x", False)
                and not getcfg("whitepoint.y", False)
            ):
                cal_changed = False
            setcfg("whitepoint.colortemp", None)
            self.whitepoint_colortemp_textctrl.SetValue(
                str(stripzeros(getcfg("whitepoint.colortemp")))
            )
            setcfg("whitepoint.x", None)
            setcfg("whitepoint.y", None)
            setcfg("3dlut.whitepoint.x", None)
            setcfg("3dlut.whitepoint.y", None)
        # Only show visual whitepoint editor if whitepoint set to chromaticity
        self.visual_whitepoint_editor_btn.Show(self.whitepoint_ctrl.GetSelection() == 2)
        self.whitepoint_measure_btn.Show(self.whitepoint_ctrl.GetSelection() > 0)
        self.calpanel.Layout()
        self.calpanel.Refresh()
        self.calpanel.Thaw()
        self.show_observer_ctrl()
        if self.whitepoint_ctrl.GetSelection() == 1:
            # Color temperature
            if getcfg("whitepoint.colortemp.locus") == "T":
                # Planckian locus
                xyY = planckianCT2xyY(getcfg("whitepoint.colortemp"))  # noqa: N806
            else:
                # Daylight locus
                xyY = CIEDCCT2xyY(getcfg("whitepoint.colortemp"))  # noqa: N806
            if xyY:
                self.whitepoint_x_textctrl.SetValue(round(xyY[0], 4))
                self.whitepoint_y_textctrl.SetValue(round(xyY[1], 4))
                setcfg("3dlut.whitepoint.x", xyY[0])
                setcfg("3dlut.whitepoint.y", xyY[1])
            else:
                self.whitepoint_x_textctrl.SetValue(0)
                self.whitepoint_y_textctrl.SetValue(0)
                setcfg("3dlut.whitepoint.x", None)
                setcfg("3dlut.whitepoint.y", None)
        if cal_changed is None and not self.updatingctrls:
            self.profile_settings_changed()
            self.update_profile_name()
        if event.GetEventType() == wx.EVT_KILL_FOCUS.evtType[0]:
            event.Skip()
        if (
            cal_changed is not False
            and not self.updatingctrls
            and not getcfg("3dlut.whitepoint.x", False)
            and not getcfg("3dlut.whitepoint.y", False)
        ):
            # Should change 3D LUT rendering intent to rel col?
            wx.CallAfter(self.check_3dlut_relcol_rendering_intent)

    def trc_type_ctrl_handler(self, event: wx.Event) -> None:
        """Handle TRC type control events.

        Args:
            event (wx.Event): The event triggered by the control.
        """
        debug_print(
            "[D] trc_type_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        v = self.get_trc_type()
        if v != getcfg("trc.type"):
            setcfg("trc.type", v)
            self.cal_changed()
            self.update_profile_name()
            self.update_trc_control()
            self.show_trc_controls(True)

    def trc_ctrl_handler(self, event: wx.Event, cal_changed: bool = True) -> None:
        """Handle TRC control events.

        Args:
            event (wx.Event): The event triggered by the control.
            cal_changed (bool): Whether the calibration has changed.
        """
        if event.GetId() == self.trc_textctrl.GetId() and (
            self.trc_ctrl.GetSelection() not in (1, 4, 7)
            or stripzeros(getcfg("trc")) == stripzeros(self.trc_textctrl.GetValue())
        ):
            event.Skip()
            self.show_trc_controls(True)
            return
        debug_print(
            "[D] trc_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        self.panel.Freeze()
        unload_cal = True
        if event.GetId() == self.trc_ctrl.GetId():
            bt1886 = (
                getcfg("trc.type") == "G"
                and getcfg("calibration.black_output_offset") == 0
                and getcfg("trc") == 2.4
            )
            if self.trc_ctrl.GetSelection() == 1:
                # Gamma 2.2
                self.trc_textctrl.SetValue("2.2")
                setcfg("trc.type", "g")
                self.trc_type_ctrl.SetSelection(0)
                setcfg("calibration.black_output_offset", 1)
                self.black_output_offset_ctrl.SetValue(100)
                self.black_output_offset_intctrl.SetValue(100)
            elif self.trc_ctrl.GetSelection() == 4:
                # BT.1886
                if not bt1886 and not getcfg("trc.backup", False):
                    setcfg("trc.backup", self.trc_textctrl.GetValue().replace(",", "."))
                    setcfg("trc.type.backup", getcfg("trc.type"))
                    setcfg(
                        "calibration.black_output_offset.backup",
                        getcfg("calibration.black_output_offset"),
                    )
                self.trc_textctrl.SetValue("2.4")
                setcfg("trc.type", "G")
                self.trc_type_ctrl.SetSelection(1)
                setcfg("calibration.black_output_offset", 0)
                self.black_output_offset_ctrl.SetValue(0)
                self.black_output_offset_intctrl.SetValue(0)
            elif self.trc_ctrl.GetSelection() not in (0, 1, 7):
                self.restore_trc_backup()
                if getcfg("calibration.black_output_offset.backup") is not None:
                    setcfg(
                        "calibration.black_output_offset",
                        getcfg("calibration.black_output_offset.backup"),
                    )
                    setcfg("calibration.black_output_offset.backup", None)
                    self.update_black_output_offset_ctrl()
            elif self.trc_ctrl.GetSelection() == 0:
                # As measured
                unload_cal = False
        if self.trc_ctrl.GetSelection() in (1, 4, 7):
            try:
                v = float(self.trc_textctrl.GetValue().replace(",", "."))
                if v == 0 or v > 10:
                    raise ValueError
            except ValueError:
                wx.Bell()
                self.trc_textctrl.SetValue(str(getcfg("trc")))
            else:
                if str(v) != self.trc_textctrl.GetValue():
                    self.trc_textctrl.SetValue(str(v))
            if (
                event.GetId() == self.trc_ctrl.GetId()
                and self.trc_ctrl.GetSelection() == 7
            ):
                # Have to use CallAfter, otherwise only part of the text will
                # be selected (wxPython bug?)
                wx.CallAfter(self.trc_textctrl.SetFocus)
                wx.CallLater(1, self.trc_textctrl.SelectAll)
        trc = self.get_trc()
        if cal_changed and trc != str(getcfg("trc")):
            if unload_cal:
                self.cal_changed()
            else:
                self.worker.options_dispcal = []
                self.profile_settings_changed()
        setcfg("trc", trc)
        if cal_changed:
            self.update_profile_name()
        if event.GetId() != self.trc_ctrl.GetId():
            self.update_trc_control()
        else:
            self.lut3d_update_apply_cal_control()
        self.update_adjustment_controls()
        self.show_trc_controls()
        self.calpanel.Layout()
        self.calpanel.Refresh()
        self.panel.Thaw()
        self.set_size(True)
        self.update_scrollbars()
        self.update_main_controls()
        if event.GetEventType() == wx.EVT_KILL_FOCUS.evtType[0]:
            event.Skip()
        if (
            trc in ("240", "709")
            and not (
                bool(int(getcfg("calibration.ambient_viewcond_adjust")))
                and getcfg("calibration.ambient_viewcond_adjust.lux")
            )
            and getcfg("trc.should_use_viewcond_adjust.show_msg")
        ):
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("trc.should_use_viewcond_adjust"),
                ok=lang.getstr("turn_on"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-information"),
                log=False,
            )
            chk = wx.CheckBox(dlg, -1, lang.getstr("dialog.do_not_show_again"))
            dlg.Bind(
                wx.EVT_CHECKBOX, self.should_use_viewcond_adjust_handler, id=chk.GetId()
            )
            dlg.sizer3.Add(chk, flag=wx.TOP | wx.ALIGN_LEFT, border=12)
            dlg.sizer0.SetSizeHints(dlg)
            dlg.sizer0.Layout()
            if dlg.ShowModal() == wx.ID_OK:
                setcfg("calibration.ambient_viewcond_adjust", 1)
                self.ambient_viewcond_adjust_cb.SetValue(True)
                self.ambient_viewcond_adjust_textctrl.Enable()
            dlg.Destroy()

    def restore_trc_backup(self) -> None:
        """Restore the TRC backup settings if available."""
        if getcfg("trc.backup"):
            setcfg("trc", getcfg("trc.backup"))
            setcfg("trc.backup", None)
            self.trc_textctrl.SetValue(str(getcfg("trc")))
        if getcfg("trc.type.backup"):
            setcfg("trc.type", getcfg("trc.type.backup"))
            setcfg("trc.type.backup", None)
            self.trc_type_ctrl.SetSelection(
                self.trc_types_ba.get(
                    getcfg("trc.type"), self.trc_types_ba.get(DEFAULTS["trc.type"])
                )
            )

    def should_use_viewcond_adjust_handler(self, event: wx.Event) -> None:
        """Handle the checkbox for showing the view condition adjustment message.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        setcfg(
            "trc.should_use_viewcond_adjust.show_msg",
            int(not event.GetEventObject().GetValue()),
        )

    def check_overwrite(self, ext: str = "", filename: None | str = None) -> bool:
        """Check if the profile file already exists and prompt the user.

        Args:
            ext (str, optional): The file extension to use if no filename is
                provided.
            filename (None | str, optional): The name of the file to check. If
                None, the default profile name with the specified extension
                will be used.

        Returns:
            bool: True if the file does not exist or the user confirms to
                overwrite, False if the user cancels the operation.
        """
        if not filename:
            filename = getcfg("profile.name.expanded") + ext
            dst_file = os.path.join(
                getcfg("profile.save_path"), getcfg("profile.name.expanded"), filename
            )
        else:
            dst_file = os.path.join(getcfg("profile.save_path"), filename)
        if os.path.exists(dst_file):
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("warning.already_exists", filename),
                ok=lang.getstr("ok"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-warning"),
            )
            result = dlg.ShowModal()
            dlg.Destroy()
            if result != wx.ID_OK:
                return False
        return True

    def measure_uniformity_handler(self, event: wx.Event) -> None:
        """Start measuring display device uniformity.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        dlg = ConfirmDialog(
            self,
            msg=lang.getstr("patch.layout.select"),
            ok=lang.getstr("ok"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, "dialog-information"),
        )
        sizer = wx.BoxSizer(wx.HORIZONTAL)
        dlg.sizer3.Add(sizer, flag=wx.TOP, border=12)
        cols = wx.Choice(
            dlg, -1, choices=list(map(str, config.VALID_VALUES["uniformity.cols"]))
        )
        rows = wx.Choice(
            dlg, -1, choices=list(map(str, config.VALID_VALUES["uniformity.rows"]))
        )
        cols.SetStringSelection(str(getcfg("uniformity.cols")))
        rows.SetStringSelection(str(getcfg("uniformity.rows")))
        sizer.Add(cols, flag=wx.ALIGN_CENTER_VERTICAL)
        sizer.Add(
            wx.StaticText(dlg, -1, "x"),
            flag=wx.LEFT | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL,
            border=4,
        )
        sizer.Add(rows, flag=wx.ALIGN_CENTER_VERTICAL)
        dlg.sizer0.SetSizeHints(dlg)
        dlg.sizer0.Layout()
        dlg.ok.SetDefault()
        result = dlg.ShowModal()
        if result == wx.ID_OK:
            setcfg("uniformity.cols", int(cols.GetStringSelection()))
            setcfg("uniformity.rows", int(rows.GetStringSelection()))
        dlg.Destroy()
        if result != wx.ID_OK:
            return
        if isinstance(getattr(self.worker, "terminal", None), DisplayUniformityFrame):
            self.worker.terminal.Destroy()
            self.worker.terminal = None
        self.HideAll()
        self.worker.interactive = True
        self.worker.start(
            self.measure_uniformity_consumer,
            self.measure_uniformity_producer,
            resume=False,
            continue_next=False,
            interactive_frame="uniformity",
        )

    def measure_uniformity_producer(self) -> None | str | Exception:
        """Produce the command to measure display device uniformity.

        Returns:
            None | str | Exception: The result of the command execution, or
                None if the command could not be found.
        """
        cmd, args = get_argyll_util("spotread"), ["-v", "-e", "-T"]
        if cmd:
            result = self.worker.add_measurement_features(args, display=False, cmd=cmd)
            if isinstance(result, Exception):
                return result
            return self.worker.exec_cmd(cmd, args, skip_scripts=True)
        wx.CallAfter(
            show_result_dialog,
            Error(lang.getstr("argyll.util.not_found", "spotread")),
            self,
        )
        return None

    def measure_uniformity_consumer(self, result: str | Exception) -> None:
        """Consume the results of the uniformity measurement.

        Args:
            result (str | Exception): The result of the measurement command.
        """
        self.Show()
        if isinstance(result, Exception):
            show_result_dialog(result, self)
            if getcfg("dry_run"):
                return
        for _i, line in enumerate(self.worker.output):
            if line.startswith("spotread: Warning"):
                show_result_dialog(Warn(line.strip()), self)

    def profile_share_get_meta_error(self, profile: ICCProfile) -> None | str:
        """Check for required metadata in profile to allow sharing.

        The treshold for average deltaE 1976 is 1.0.

        Arguments:
            profile (ICCProfile): The ICC profile to check.

        Returns:
            None | str: An error message if the profile is not suitable for
                sharing, or None if the profile is suitable for sharing.
        """
        if "meta" not in profile.tags or not isinstance(profile.tags.meta, DictType):
            return lang.getstr("profile.share.meta_missing")

        try:
            avg_dE76 = float(profile.tags.meta.getvalue("ACCURACY_dE76_avg"))  # noqa: N806
        except (TypeError, ValueError):
            return lang.getstr("profile.share.meta_missing")
        threshold = 1.0
        if avg_dE76 and avg_dE76 > threshold:
            return lang.getstr(
                "profile.share.avg_dE_too_high",
                (f"{avg_dE76:.2f}", f"{threshold:.2f}"),
            )
        # Check for EDID metadata
        metadata = profile.tags.meta
        if "EDID_mnft" in metadata:
            # Check and correct manufacturer if necessary
            manufacturer = get_manufacturer_name(metadata["EDID_mnft"])
            if manufacturer:
                manufacturer = colord.quirk_manufacturer(manufacturer)
                if (
                    "EDID_manufacturer" not in metadata
                    or metadata["EDID_manufacturer"] != manufacturer
                ):
                    metadata["EDID_manufacturer"] = manufacturer
        if (
            "EDID_model_id" not in metadata
            or ("EDID_model" not in metadata and metadata["EDID_model_id"] == "0")
            or "EDID_mnft_id" not in metadata
            or "EDID_mnft" not in metadata
            or "EDID_manufacturer" not in metadata
            or "OPENICC_automatic_generated" not in metadata
        ):
            return lang.getstr("profile.share.meta_missing")
        if (
            "B2A0" in profile.tags
            and isinstance(profile.tags.B2A0, LUT16Type)
            and profile.tags.B2A0.input_entries_count < 1024
        ):
            # 1024 is the Argyll value for a medium quality profile
            return lang.getstr("profile.share.b2a_resolution_too_low")
        return None

    def profile_share_handler(self, event: wx.Event) -> None:
        """Share ICC profile via http://icc.opensuse.org.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        # as mentioned in #194 the icc.opensuse.org is not working,
        # disabling this functionality temporarily
        InfoDialog(
            getattr(self, "modaldlg", self),
            msg="icc.opensuse.org is not working anymore\n"
            "This functionality is temporarily disabled.",
            ok=lang.getstr("ok"),
            bitmap=get_icon(32, "dialog-error"),
        )
        return

        # Select profile
        profile = get_current_profile(include_display_profile=True)
        ignore = not profile or self.profile_share_get_meta_error(profile)
        kwargs = {
            "ignore_current_profile": ignore,
            "prefer_current_profile": isinstance(event.EventObject, wx.Button),
            "title": lang.getstr("profile.share"),
        }
        profile = self.select_profile(**kwargs)
        if not profile:
            return

        # Check meta and profcheck data
        error = self.profile_share_get_meta_error(profile)
        if error:
            InfoDialog(
                getattr(self, "modaldlg", self),
                msg=error,
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            return

        # Get options from profile
        options_dispcal, options_colprof = get_options_from_profile(profile)
        gamma = None
        for option in options_dispcal:
            if option.startswith(("g", "G")):
                option = option[1:]
                gamma = {
                    "240": "SMPTE 240M",
                    "709": "Rec. 709",
                    "l": "L*",
                    "s": "sRGB",
                }.get(option, f"Gamma {option}")

        metadata = profile.tags.meta
        # Model will be shown in overview on http://icc.opensuse.org
        model = metadata.getvalue(
            "EDID_model",
            profile.getDeviceModelDescription() or metadata["EDID_model_id"],
            None,
        )
        description = model
        date = metadata.getvalue("EDID_date", "", None).split("-T")
        if len(date) == 2:
            year = int(date[0])
            week = int(date[1])
            date = datetime.date(int(year), 1, 1) + datetime.timedelta(weeks=week)
            description += " '" + strftime("%y", date.timetuple())
        if isinstance(profile.tags.get("vcgt"), VideoCardGammaType):
            vcgt = "linear VCGT" if profile.tags.vcgt.is_linear() else "VCGT"
        else:
            vcgt = "no VCGT"
        if vcgt:
            description += ", " + vcgt
        whitepoint = f"{round(XYZ2CCT(*list(profile.tags.wtpt.values()))):.0f}K"
        description += ", " + whitepoint
        description += f", {profile.tags.lumi.Y:0.f} cd/m²"
        if gamma:
            description += f", {gamma}"
        instrument = metadata.getvalue("MEASUREMENT_device")
        if instrument:
            for instrument_name in instruments:
                if instrument_name.lower() == instrument:
                    instrument = instrument_name
                    break
            description += ", " + instrument
        description += ", " + strftime("%Y-%m-%d", profile.dateTime.timetuple())
        dlg = ConfirmDialog(
            getattr(self, "modaldlg", self),
            title=lang.getstr("profile.share"),
            msg=lang.getstr("profile.share.enter_info"),
            ok=lang.getstr("upload"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, APPNAME + "-profile-info"),
            alt=lang.getstr("save"),
            wrap=100,
        )
        # Description field
        boxsizer = wx.StaticBoxSizer(
            wx.StaticBox(dlg, -1, lang.getstr("description")), wx.VERTICAL
        )
        dlg.sizer3.Add(boxsizer, 1, flag=wx.TOP | wx.EXPAND, border=12)
        if sys.platform not in ("darwin", "win32"):
            boxsizer.Add((1, 8))
        dlg.description_txt_ctrl = wx.TextCtrl(dlg, -1, description)
        boxsizer.Add(dlg.description_txt_ctrl, 1, flag=wx.ALL | wx.EXPAND, border=4)
        # Display properties
        boxsizer = wx.StaticBoxSizer(
            wx.StaticBox(dlg, -1, lang.getstr("display.properties")), wx.VERTICAL
        )
        dlg.sizer3.Add(boxsizer, 1, flag=wx.TOP | wx.EXPAND, border=12)
        if sys.platform not in ("darwin", "win32"):
            boxsizer.Add((1, 8))
        box_gridsizer = wx.FlexGridSizer(0, 1, 0, 0)
        boxsizer.Add(box_gridsizer, 1, flag=wx.ALL, border=4)
        # Display panel surface type, connection
        gridsizer = wx.FlexGridSizer(0, 4, 4, 8)
        box_gridsizer.Add(gridsizer, 1, wx.ALIGN_LEFT)
        # Panel surface type
        gridsizer.Add(
            wx.StaticText(dlg, -1, lang.getstr("panel.surface")),
            1,
            flag=wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL,
        )
        paneltypes = ["glossy", "matte"]
        dlg.panel_ctrl = wx.Choice(
            dlg, -1, choices=[""] + [lang.getstr(panel) for panel in paneltypes]
        )
        panel_surface = metadata.getvalue("SCREEN_surface", "")
        try:
            index = dlg.panel_ctrl.GetItems().index(lang.getstr(panel_surface))
        except ValueError:
            index = 0
        dlg.panel_ctrl.SetSelection(index)
        gridsizer.Add(
            dlg.panel_ctrl,
            1,
            flag=wx.RIGHT | wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL,
            border=8,
        )
        # Connection type
        gridsizer.Add(
            wx.StaticText(dlg, -1, lang.getstr("display.connection.type")),
            1,
            flag=wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL,
        )
        connections = ["dvi", "displayport", "hdmi", "internal", "vga"]
        dlg.connection_ctrl = wx.Choice(
            dlg, -1, choices=[lang.getstr(contype) for contype in connections]
        )
        connection_type = metadata.getvalue("CONNECTION_type", "dvi")
        try:
            index = dlg.connection_ctrl.GetItems().index(lang.getstr(connection_type))
        except ValueError:
            index = 0
        dlg.connection_ctrl.SetSelection(index)
        gridsizer.Add(
            dlg.connection_ctrl,
            1,
            flag=wx.RIGHT | wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL,
            border=8,
        )
        if sys.platform == "darwin":
            display_settings_tabs = wx.Notebook(dlg, -1)
        else:
            display_settings_tabs = aui.AuiNotebook(dlg, -1, style=aui.AUI_NB_TOP)
            display_settings_tabs._agwFlags = aui.AUI_NB_TOP
            try:
                art = AuiBetterTabArt()
                if sys.platform == "win32":
                    art.SetDefaultColours(aui.StepColour(dlg.BackgroundColour, 96))
                display_settings_tabs.SetArtProvider(art)
            except Exception as exception:
                print(exception)
        dlg.display_settings = display_settings_tabs
        # Column layout
        scale = getcfg("app.dpi") / config.get_default_dpi()
        scale = max(scale, 1)
        display_settings = (
            (  # 1st tab
                lang.getstr("osd") + ": " + lang.getstr("settings.basic"),  # Tab title
                2,  # Number of columns
                (  # 1st (left) column
                    (
                        ("preset", 150),
                        ("brightness", 50),
                        ("contrast", 50),
                        ("trc.gamma", 50),
                        ("blacklevel", 50),
                        ("hue", 50),
                    ),
                    # 2nd (right) column
                    (
                        ("", 0),
                        ("whitepoint.colortemp", 125),
                        ("whitepoint", 50),
                        ("saturation", 50),
                    ),
                ),
            ),
            (  # 2nd tab
                lang.getstr("osd")
                + ": "
                + lang.getstr("settings.additional"),  # Tab title
                3,  # Number of columns
                (  # 1st (left) column
                    (("hue", 50),),
                    # 2nd (middle) column
                    (("offset", 50),),
                    # 3rd (right) column
                    (("saturation", 50),),
                ),
            ),
        )
        display_settings_ctrls = []
        for tab_num, settings in enumerate(display_settings):
            panel = wx.Panel(display_settings_tabs, -1)
            panel.SetSizer(wx.BoxSizer(wx.VERTICAL))
            gridsizer = wx.FlexGridSizer(0, settings[1] * 2, 4, 12)
            panel.GetSizer().Add(gridsizer, 1, wx.ALL | wx.EXPAND, border=8)
            display_settings_tabs.AddPage(panel, settings[0])
            ctrls = []
            texts = []
            for column in settings[2]:
                for name, width in column:
                    if name in ("whitepoint",):
                        components = ("red", "green", "blue")
                    elif tab_num == 1 and name in ("hue", "offset", "saturation"):
                        components = (
                            "red",
                            "green",
                            "blue",
                            "cyan",
                            "magenta",
                            "yellow",
                        )
                    else:
                        components = ("",)
                    nameprefix = name
                    for component in components:
                        if component:
                            name = nameprefix + "_" + component
                        if name:
                            label = name
                            if "_" in label:
                                label = label.split("_")
                                for i, part in enumerate(label):
                                    label[i] = lang.getstr(part)
                                label = " ".join(label)
                            else:
                                label = lang.getstr(label)
                            text = wx.StaticText(panel, -1, label)
                            ctrl = wx.TextCtrl(
                                panel,
                                -1,
                                metadata.getvalue(
                                    "OSD_settings_{}".format(
                                        re.sub(r"[ .]", "_", name)
                                    ),
                                    "",
                                ),
                                size=(width * scale, -1),
                                name=name,
                            )
                        else:
                            text = (0, 0)
                            ctrl = (0, 0)
                        texts.append(text)
                        ctrls.append(ctrl)
                        display_settings_ctrls.append(ctrl)
            # Add the controls to the sizer
            rows = math.ceil(len(ctrls) / float(settings[1]))
            for row_num in range(rows):
                for column_num in range(settings[1]):
                    ctrl_index = row_num + column_num * rows
                    if ctrl_index < len(ctrls):
                        gridsizer.Add(
                            texts[ctrl_index],
                            1,
                            flag=wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_LEFT,
                        )
                        gridsizer.Add(
                            ctrls[ctrl_index],
                            1,
                            flag=wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_LEFT | wx.RIGHT,
                            border=4,
                        )
            if isinstance(display_settings_tabs, aui.AuiNotebook):
                if sys.platform != "win32":
                    display_settings_tabs.SetTabCtrlHeight(
                        display_settings_tabs.GetTabCtrlHeight() + 2
                    )
                height = display_settings_tabs.GetHeightForPageHeight(
                    panel.Sizer.MinSize[1]
                )
            else:
                height = -1
            display_settings_tabs.SetMinSize((dlg.sizer3.MinSize[0] - 16, height))
        box_gridsizer.Add(
            display_settings_tabs, 1, flag=wx.TOP | wx.ALIGN_LEFT, border=8
        )
        # License field
        # dlg.sizer3.Add(wx.StaticText(dlg, -1, lang.getstr("license")), 1,
        # flag=wx.TOP | wx.ALIGN_LEFT, border=12)
        # dlg.license_ctrl = wx.Choice(dlg, -1,
        # choices=["http://www.color.org/registry/icc_license_2011.txt",
        # "http://www.gzip.org/zlib/zlib_license.html"])
        # dlg.license_ctrl.SetSelection(0)
        # sizer4 = wx.BoxSizer(wx.HORIZONTAL)
        # dlg.sizer3.Add(sizer4, 1,
        # flag=wx.TOP | wx.ALIGN_LEFT, border=4)
        # sizer4.Add(dlg.license_ctrl, 1,
        # flag=wx.RIGHT | wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL,
        # border=8)
        # License link button
        # dlg.license_link_ctrl = wx.BitmapButton(dlg, -1,
        # get_icon(16, "dialog-information"),
        # style=wx.NO_BORDER)
        # dlg.license_link_ctrl.SetToolTipString(lang.getstr("license"))
        # dlg.Bind(wx.EVT_BUTTON,
        # lambda event: launch_file(dlg.license_ctrl.GetValue()),
        # dlg.license_link_ctrl)
        # sizer4.Add(dlg.license_link_ctrl, flag=wx.ALIGN_LEFT |
        # wx.ALIGN_CENTER_VERTICAL)
        # Link to ICC Profile Taxi service
        hyperlink = HyperLinkCtrl(
            dlg.buttonpanel,
            -1,
            label="icc.opensuse.org",
            URL="https://icc.opensuse.org/",
        )
        dlg.sizer2.Insert(
            0,
            hyperlink,
            flag=wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL | wx.LEFT,
            border=round(32 + 12),
        )
        dlg.description_txt_ctrl.SetFocus()
        dlg.sizer0.SetSizeHints(dlg)
        dlg.sizer0.Layout()
        dlg.Center()
        result = dlg.ShowModal()
        if result == wx.ID_CANCEL:
            return
        # Get meta prefix
        prefixes = (metadata.getvalue("prefix", "", None) or "CONNECTION_").split(",")
        if "CONNECTION_" not in prefixes:
            prefixes.append("CONNECTION_")
        # Update meta
        panel = dlg.panel_ctrl.GetSelection()
        if panel > 0:
            metadata["SCREEN_surface"] = paneltypes[panel - 1]
            if "SCREEN_" not in prefixes:
                prefixes.append("SCREEN_")
        # Update meta
        metadata["CONNECTION_type"] = connections[dlg.connection_ctrl.GetSelection()]
        for ctrl in display_settings_ctrls:
            if isinstance(ctrl, wx.TextCtrl) and ctrl.GetValue().strip():
                metadata["OSD_settings_{}".format(re.sub(r"[ .]", "_", ctrl.Name))] = (
                    ctrl.GetValue().strip()
                )
            if "OSD_" not in prefixes:
                prefixes.append("OSD_")
        # Set meta prefix
        metadata["prefix"] = ",".join(prefixes)
        # Calculate profile ID
        profile.calculate_id()
        # Save profile
        try:
            profile.write()
        except OSError as exception:
            show_result_dialog(exception, self)
        if result != wx.ID_OK:
            return
        # Get profile data
        data = profile.data
        # Add metadata which should not be reflected in profile
        metadata["model"] = model
        metadata["vcgt"] = int("vcgt" in profile.tags)
        # Upload
        params = {
            "description": dlg.description_txt_ctrl.GetValue(),
            # "licence": dlg.license_ctrl.GetValue()}
            "licence": "http://www.color.org/registry/icc_license_2011.txt",
        }
        files = [
            (
                "metadata",
                "metadata.json",
                '{"org":{"freedesktop":{"openicc":{"device":{"monitor":['
                f"{metadata.to_json()}]}}}}}}}}}}",
            ),
            ("profile", "profile.icc", data),
        ]
        self.worker.interactive = False
        self.worker.start(
            self.profile_share_consumer,
            http_request,
            ckwargs={},
            wkwargs={
                "domain": DOMAIN if TEST else "icc.opensuse.org",
                "request_type": "POST",
                "path": "/print_r_post.php" if TEST else "/upload",
                "params": params,
                "files": files,
            },
            progress_msg=lang.getstr("profile.share"),
            stop_timers=False,
            cancelable=False,
            show_remaining_time=False,
            fancy=False,
        )

    def profile_share_consumer(
        self, result: bool | str | Exception, parent: None | wx.Window = None
    ) -> None:
        """This function receives the response from the profile upload.

        Args:
            result (bool | str | Exception): The result of the upload
                operation. If the upload was successful, it will be a string
                containing the URL of the uploaded profile. If it failed, it
                will be False or an Exception.
            parent (None | wx.Window, optional): The parent window for the
                dialog. If not provided, it defaults to the current modal
                dialog or the main window of the application.
        """
        if result is False:
            return

        parent = parent or getattr(self, "modaldlg", self)
        dlg = InfoDialog(
            parent,
            msg=lang.getstr("profile.share.success"),
            ok=lang.getstr("ok"),
            bitmap=get_icon(32, "dialog-information"),
            show=False,
        )
        # Link to ICC Profile Taxi service
        hyperlink = HyperLinkCtrl(
            dlg.buttonpanel,
            -1,
            label="icc.opensuse.org",
            URL="https://icc.opensuse.org/",
        )
        border = dlg.sizer3.MinSize[0] - dlg.sizer2.MinSize[0] - hyperlink.Size[0]
        border = max(border, 24)
        dlg.sizer2.Insert(
            0,
            hyperlink,
            flag=wx.ALIGN_LEFT | wx.ALIGN_CENTER_VERTICAL | wx.RIGHT,
            border=border,
        )
        dlg.sizer2.Insert(0, (44, 1))
        dlg.sizer0.SetSizeHints(dlg)
        dlg.sizer0.Layout()
        dlg.ok.SetDefault()
        dlg.ShowModalThenDestroy()

    def install_argyll_instrument_conf(
        self, event: None | wx.Event = None, uninstall: bool = False
    ) -> None:
        """Install or uninstall Argyll instrument configuration files.

        Args:
            event (None | wx.Event, optional): The event that triggered this
                handler.
            uninstall (bool, optional): If True, uninstall the configuration
                files instead of installing them.
        """
        filenames = None
        cmd = "cp"

        if uninstall:
            if filenames := get_argyll_instrument_config("installed"):
                dlgs = []
                dlg = ConfirmDialog(
                    self,
                    title=lang.getstr(
                        "argyll.instrument.configuration_files.uninstall"
                    ),
                    msg=lang.getstr("dialog.confirm_uninstall"),
                    ok=lang.getstr("uninstall"),
                    cancel=lang.getstr("cancel"),
                    bitmap=get_icon(32, "dialog-warning"),
                )
                dlgs.append(dlg)
                dlg.sizer3.Add((0, 8))
                chks = []
                for filename in filenames:
                    dlg.sizer3.Add((0, 4))
                    chk = wx.CheckBox(dlg, -1, filename)
                    chks.append(chk)
                    chk.SetValue(True)
                    dlg.sizer3.Add(chk, flag=wx.ALIGN_LEFT)
                dlg.sizer0.SetSizeHints(dlg)
                dlg.sizer0.Layout()
                dlg.Center()
                result = dlg.ShowModal()
                filenames = []
                if result == wx.ID_OK:
                    for chk in chks:
                        if chk.GetValue():
                            filenames.append(chk.Label)
                for filename in filenames:
                    if os.path.dirname(filename) == "/lib/udev/rules.d":
                        dlg = ConfirmDialog(
                            self,
                            title=lang.getstr(
                                "argyll.instrument.configuration_files.uninstall"
                            ),
                            msg=lang.getstr("warning.system_file", filename),
                            ok=lang.getstr("continue"),
                            cancel=lang.getstr("cancel"),
                            bitmap=get_icon(32, "dialog-warning"),
                        )
                        dlgs.append(dlg)
                        result = dlg.ShowModal()
                        if result != wx.ID_OK:
                            break
                for dlg in dlgs:
                    dlg.Destroy()
            if not filenames or result != wx.ID_OK:
                return
            cmd = "rm"

        result = self.worker.authenticate(which(cmd))
        if result not in (True, None):
            if isinstance(result, Exception):
                show_result_dialog(result, self)
            return
        self.worker.start(
            self.install_argyll_instrument_conf_consumer,
            self.worker.install_argyll_instrument_conf,
            ckwargs={"uninstall": uninstall},
            wkwargs={"uninstall": uninstall, "filenames": filenames},
            fancy=False,
        )

    def install_argyll_instrument_conf_consumer(
        self, result: bool | Exception, uninstall: bool = False
    ) -> None:
        """Consumer for installing/uninstalling Argyll instrument configuration files.

        Args:
            result (bool or Exception): The result of the installation or
                uninstallation process.
            uninstall (bool): If True, the process is an uninstallation.
        """
        if isinstance(result, Exception):
            show_result_dialog(result, self)
        elif result is False:
            show_result_dialog(Error("".join(self.worker.errors)), self)
        else:
            self.update_menus()
            if uninstall:
                msgid = "argyll.instrument.configuration_files.uninstall.success"
            else:
                msgid = "argyll.instrument.configuration_files.install.success"
            show_result_dialog(Info(lang.getstr(msgid)), self)

    def install_argyll_instrument_drivers(
        self, event: None | wx.Event = None, uninstall: bool = False
    ) -> None:
        """Install or uninstall Argyll instrument drivers.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
            uninstall (bool, optional): If True, uninstall the drivers instead
                of installing them.
        """
        if uninstall:
            title = "argyll.instrument.drivers.uninstall"
            msg = "argyll.instrument.drivers.uninstall.confirm"
            ok = "continue"
        else:
            title = "argyll.instrument.drivers.install"
            msg = "argyll.instrument.drivers.install.confirm"
            ok = "download_install"
        dlg = ConfirmDialog(
            self,
            title=lang.getstr(title),
            msg=lang.getstr(msg),
            ok=lang.getstr(ok).replace("&", "&&"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, "dialog-information"),
        )
        dlg.launch_devman = wx.CheckBox(dlg, -1, lang.getstr("device_manager.launch"))
        dlg.launch_devman.SetValue(uninstall)
        dlg.sizer3.Add(dlg.launch_devman, flag=wx.TOP | wx.ALIGN_LEFT, border=12)
        if hasattr(dlg.ok, "SetAuthNeeded"):
            dlg.ok.SetAuthNeeded(True)
        dlg.sizer0.SetSizeHints(dlg)
        dlg.sizer0.Layout()
        result = dlg.ShowModal()
        launch_devman = dlg.launch_devman.IsChecked()
        dlg.Destroy()
        if result != wx.ID_OK:
            return
        print("-" * 80)
        print(lang.getstr(title))
        self.worker.start(
            lambda result: (
                show_result_dialog(result, self)
                if isinstance(result, Exception)
                else self.check_update_controls(True)
            ),
            self.worker.install_argyll_instrument_drivers,
            wargs=(uninstall, launch_devman),
            fancy=False,
        )

    def uninstall_argyll_instrument_conf(self, event: wx.Event = None) -> None:
        """Uninstall Argyll instrument configuration files.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        self.install_argyll_instrument_conf(uninstall=True)

    def uninstall_argyll_instrument_drivers(
        self, event: None | wx.Event = None
    ) -> None:
        """Uninstall Argyll instrument drivers.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        self.install_argyll_instrument_drivers(uninstall=True)

    def install_profile_handler(
        self,
        event: None | wx.Event = None,
        profile_path: None | str = None,
        install_3dlut: None | bool = None,
    ) -> None:
        """Install a profile.

        Show an error dialog if the profile is invalid or unsupported (only
        'mntr' RGB profiles are allowed).

        Args:
            event (wx.Event): The event that triggered this handler.
            profile_path (str, optional): Path to the profile to install. If
                not provided, it will use the path from the configuration.
            install_3dlut (bool, optional): Whether to install a 3D LUT. If
                not provided, it will use the current state of the LUT3D
                settings panel.
        """
        if not check_set_argyll_bin():
            return
        if profile_path is None:
            profile_path = getcfg("calibration.file", False)
        if profile_path:
            result = check_file_isfile(profile_path)
            if isinstance(result, Exception):
                show_result_dialog(result, self)
        else:
            result = False
        if install_3dlut is None:
            install_3dlut = self.lut3d_settings_panel.IsShown()
        if isinstance(result, Exception) or not result:
            return
        try:
            profile = ICCProfile(profile_path)
        except (OSError, ICCProfileInvalidError):
            InfoDialog(
                self,
                msg=lang.getstr("profile.invalid") + "\n" + profile_path,
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            return

        if profile.profileClass != b"mntr" or profile.colorSpace != b"RGB":
            InfoDialog(
                self,
                msg=lang.getstr(
                    "profile.unsupported",
                    (profile.profileClass, profile.colorSpace),
                )
                + "\n"
                + profile_path,
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            return
        setcfg("calibration.file.previous", getcfg("calibration.file", False))
        self.profile_finish(
            True,
            profile_path=profile_path,
            skip_scripts=True,
            allow_show_log=False,
            install_3dlut=install_3dlut,
        )

    def select_install_profile_handler(self, event: wx.Event) -> None:
        """Show a dialog for user to select a profile for installation.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        default_dir, default_file = get_verified_path("last_icc_path")
        dlg = wx.FileDialog(
            self,
            lang.getstr("install_display_profile"),
            defaultDir=default_dir,
            defaultFile=default_file,
            wildcard=lang.getstr("filetype.icc") + "|*.icc;*.icm",
            style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
        )
        dlg.Center(wx.BOTH)
        result = dlg.ShowModal()
        path = dlg.GetPath()
        dlg.Destroy()
        if result == wx.ID_OK:
            setcfg("last_icc_path", path)
            setcfg("last_cal_or_icc_path", path)
            self.install_profile_handler(profile_path=path, install_3dlut=False)

    def load_profile_cal_handler(self, event: wx.Event) -> None:
        """Prompt user to select a profile for loading calibration (vcgt).

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if not check_set_argyll_bin():
            return
        default_dir, default_file = get_verified_path("last_cal_or_icc_path")
        dlg = wx.FileDialog(
            self,
            lang.getstr("calibration.load_from_cal_or_profile"),
            defaultDir=default_dir,
            defaultFile=default_file,
            wildcard=lang.getstr("filetype.cal_icc") + "|*.cal;*.icc;*.icm",
            style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
        )
        dlg.Center(wx.BOTH)
        result = dlg.ShowModal()
        path = dlg.GetPath()
        dlg.Destroy()
        if result == wx.ID_OK:
            if not os.path.exists(path):
                InfoDialog(
                    self,
                    msg=lang.getstr("file.missing", path),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                return
            setcfg("last_cal_or_icc_path", path)
            verbose_print(lang.getstr("calibration.loading"))
            verbose_print(path)
            if os.path.splitext(path)[1].lower() in (".icc", ".icm"):
                try:
                    profile = ICCProfile(path)
                except (OSError, ICCProfileInvalidError):
                    verbose_print(lang.getstr("failure"))
                    InfoDialog(
                        self,
                        msg=lang.getstr("profile.invalid") + "\n" + path,
                        ok=lang.getstr("ok"),
                        bitmap=get_icon(32, "dialog-error"),
                    )
                    return
                setcfg("last_icc_path", path)
                if (
                    self.install_cal(
                        capture_output=True,
                        profile_path=path,
                        skip_scripts=True,
                        silent=not getcfg("dry_run"),
                        title=lang.getstr("calibration.load_from_profile"),
                    )
                    is True
                ):
                    self.lut_viewer_load_lut(profile=profile)
                    verbose_print(lang.getstr("success"))
                elif not getcfg("dry_run"):
                    verbose_print(lang.getstr("failure"))
                    InfoDialog(
                        self,
                        msg=lang.getstr("calibration.load_error") + "\n" + path,
                        ok=lang.getstr("ok"),
                        bitmap=get_icon(32, "dialog-error"),
                    )
            else:
                setcfg("last_cal_path", path)
                if (
                    self.install_cal(
                        capture_output=True,
                        cal=path,
                        skip_scripts=True,
                        silent=not getcfg("dry_run"),
                        title=lang.getstr("calibration.load_from_cal"),
                    )
                    is True
                ):
                    self.lut_viewer_load_lut(profile=cal_to_fake_profile(path))
                    verbose_print(lang.getstr("success"))
                elif not getcfg("dry_run"):
                    verbose_print(lang.getstr("failure"))

    def preview_handler(
        self, event: None | wx.Event = None, preview: bool = False
    ) -> None:
        """Preview profile calibration (vcgt).

        Toggle between profile curves and previous calibration curves.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
            preview (bool, optional): Whether to use the preview checkbox value.
        """
        if preview or self.preview.GetValue():
            cal = self.cal
        else:
            cal = getcfg("calibration.file.previous")
            if self.cal == cal:
                cal = False
            elif not cal:
                cal = True
        if cal is False:  # linear
            profile = None
        elif cal is True:  # display profile
            profile = get_display_profile()
            if not profile:
                cal = False
        elif cal.lower().endswith(".icc") or cal.lower().endswith(".icm"):
            try:
                profile = ICCProfile(cal)
            except (OSError, ICCProfileInvalidError) as exception:
                show_result_dialog(exception, self)
                profile = None
        else:
            profile = cal_to_fake_profile(cal)
        if profile:
            verbose_print(lang.getstr("calibration.loading"))
            if profile.filename:
                verbose_print(profile.filename)
        else:
            verbose_print(lang.getstr("calibration.resetting"))
        if (
            self.install_cal(
                capture_output=True,
                cal=cal,
                skip_scripts=True,
                silent=True,
                title=lang.getstr("calibration.load_from_cal_or_profile"),
            )
            is True
        ):
            self.lut_viewer_load_lut(profile=profile)
            verbose_print(lang.getstr("success"))
        else:
            verbose_print(lang.getstr("failure"))

    def profile_load_on_login_handler(self, event: None | wx.Event = None) -> None:
        """Handle the profile load on login checkbox event.

        Args:
            event (None | wx.Event, optional): The event that triggered this
                handler.
        """
        setcfg("profile.load_on_login", int(self.profile_load_on_login.GetValue()))
        if sys.platform == "win32" and sys.getwindowsversion() >= (6, 1):
            self.profile_load_on_login.Enable(
                is_superuser() or not util_win.calibration_management_isenabled()
            )
            self.profile_load_by_os.Enable(
                is_superuser() and self.profile_load_on_login.GetValue()
            )
            if (
                not self.profile_load_on_login.GetValue()
                and self.profile_load_by_os.GetValue()
                and is_superuser()
            ):
                self.profile_load_by_os.SetValue(False)
                self.profile_load_by_os_handler()
        # Update profile loader config
        if sys.platform == "win32" and event:
            prev = self.send_command("apply-profiles", "getcfg profile.load_on_login")
            if prev:
                with contextlib.suppress(Exception):
                    prev = int(prev.split()[-1])
                result = self.send_command(
                    "apply-profiles",
                    "setcfg profile.load_on_login {:.0f}".format(
                        getcfg("profile.load_on_login")
                    ),
                )
                if result == "ok" and getcfg("profile.load_on_login") != prev:
                    if getcfg("profile.load_on_login"):
                        lstr = "calibration.preserve"
                    else:
                        lstr = "profile_loader.disable"
                    self.send_command("apply-profiles", f"notify '{lang.getstr(lstr)}'")
            else:
                # Profile loader not running? Fall back to config files

                # 1. Remember current config
                items = config.CFG.items(config.configparser.DEFAULTSECT)

                # 2. Read in profile loader config. Result is unison of current
                #    config and profile loader config.
                initcfg("apply-profiles", force_load=True)

                # 3. Restore current config (but do not override profile loader
                #    options)
                for name, value in items:
                    if not name.startswith("profile_loader"):
                        config.CFG.set(config.configparser.DEFAULTSECT, name, value)

                # 4. Write profile loader config with values updated from
                #    current config
                writecfg(
                    module="apply-profiles",
                    options=("profile.load_on_login", "profile_loader"),
                )

                # 5. Remove profile loader options from current config
                for name in DEFAULTS:
                    if name.startswith("profile_loader"):
                        setcfg(name, None)

    def profile_load_by_os_handler(self, event: None | wx.Event = None) -> None:
        """Handle the profile load by OS checkbox event.

        Args:
            event (None | wx.Event, optional): The event that triggered this
                handler.
        """
        if not is_superuser():
            return
        # Enable calibration management under Windows 7
        try:
            util_win.enable_calibration_management(self.profile_load_by_os.GetValue())
        except Exception as exception:
            print(f"util_win.enable_calibration_management(True): {exception}")
        else:
            label = get_profile_load_on_login_label(self.profile_load_by_os.GetValue())
            self.profile_load_on_login.Label = label
            self.profile_load_on_login.ContainingSizer.Layout()

    def install_cal(
        self,
        capture_output: bool = False,
        cal: None | bool | str = None,
        profile_path: None | str = None,
        skip_scripts: bool = False,
        silent: bool = False,
        title: str = APPNAME,
    ) -> bool | Exception:
        """Install (load) a calibration from a calibration file or profile.

        Args:
            capture_output (bool): Whether to capture output from the command.
            cal (None | bool | str, optional): Path to the calibration file or
                True for display profile, False to reset calibration. None
                if the current calibration file from the configuration should
                be used.
            profile_path (None | str, optional): Path to the profile file to
                load.
            skip_scripts (bool): Whether to skip running scripts after loading
                the calibration.
            silent (bool): Whether to suppress dialog messages.
            title (str): Title for the dialog.

        Returns:
            bool | Exception: True if the calibration was successfully loaded,
                False if it was reset, or an Exception if an error occurred.
        """
        if config.is_virtual_display():
            return True
        # Install using dispwin
        cmd, args = self.worker.prepare_dispwin(cal, profile_path, False)
        if not isinstance(cmd, Exception):
            result = self.worker.exec_cmd(
                cmd,
                args,
                capture_output,
                low_contrast=False,
                skip_scripts=skip_scripts,
                silent=silent,
                title=title,
            )
        else:
            result = cmd
        if not isinstance(result, Exception) and result:
            if not silent:
                if cal is False:
                    InfoDialog(
                        self,
                        msg=lang.getstr("calibration.reset_success"),
                        ok=lang.getstr("ok"),
                        bitmap=get_icon(32, "dialog-information"),
                        log=False,
                    )
                else:
                    InfoDialog(
                        self,
                        msg=lang.getstr("calibration.load_success"),
                        ok=lang.getstr("ok"),
                        bitmap=get_icon(32, "dialog-information"),
                        log=False,
                    )
        elif not silent:
            if isinstance(result, Exception) and getcfg("dry_run"):
                show_result_dialog(result, self)
                return None
            if cal is False:
                InfoDialog(
                    self,
                    msg=lang.getstr("calibration.reset_error"),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                    log=False,
                )
            else:
                InfoDialog(
                    self,
                    msg=lang.getstr("calibration.load_error"),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                    log=False,
                )
        return result

    def update_measurement_report(self, event: None | wx.Event = None) -> None:
        """Show file dialog to select a HTML measurement report for updating.

        Update the selected report and show it afterwards.

        Args:
            event (wx.Event, optional): The event that triggered the update.
        """
        default_dir, default_file = get_verified_path("last_filedialog_path")
        dlg = wx.FileDialog(
            self,
            lang.getstr("measurement_report.update"),
            defaultDir=default_dir,
            defaultFile=default_file,
            wildcard=f"{lang.getstr('filetype.html')}|*.html;*.htm",
            style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
        )
        dlg.Center(wx.BOTH)
        result = dlg.ShowModal()
        if result == wx.ID_OK:
            path = dlg.GetPath()
            setcfg("last_filedialog_path", path)
        dlg.Destroy()
        if result != wx.ID_OK:
            return
        try:
            report.update(path, pack=getcfg("report.pack_js"))
        except OSError as exception:
            show_result_dialog(exception)
        else:
            # show report
            wx.CallAfter(launch_file, path)

    def verify_calibration_handler(self, event: wx.Event) -> None:
        """Handler for verifying the current calibration.

        Args:
            event (wx.Event): The event that triggered the verification.
        """
        if check_set_argyll_bin():
            self.setup_measurement(self.verify_calibration)

    def verify_calibration(self) -> None:
        """Verify the current calibration by measuring the display."""
        if self.measure_auto(self.verify_calibration):
            return
        print("-" * 80)
        self.report_title = lang.getstr("calibration.verify")
        print(self.report_title)
        self.worker.interactive = False
        self.worker.start(
            self.result_consumer,
            self.worker.verify_calibration,
            progress_msg=self.report_title,
            pauseable=True,
            resume=bool(getattr(self, "measure_auto_after", None)),
        )

    def select_profile(
        self,
        parent: None | wx.Window = None,
        title: str = APPNAME,
        msg: None | str = None,
        check_profile_class: bool = True,
        ignore_current_profile: bool = False,
        prefer_current_profile: bool = False,
    ) -> None | ICCProfile:
        """Select the currently configured profile or display profile.

        Falls back to user choice via FileDialog if both not set.

        Args:
            parent (wx.Window, optional): Parent window for dialogs. Defaults
                to self.
            title (str, optional): Title for the dialog. Defaults to APPNAME.
            msg (str, optional): Message to display in the dialog. Defaults to
                None.
            check_profile_class (bool, optional): Whether to check the profile
                class and color space of the selected profile. Defaults to
                True.
            ignore_current_profile (bool, optional): If True, do not use the
                current profile as a fallback. Defaults to False.
            prefer_current_profile (bool, optional): If True, prefer the
                current profile over user selection. Defaults to False.

        Returns:
            None | ICCProfile: The selected profile, or None if no valid
                profile was selected or the user canceled the dialog.
        """
        if not parent:
            parent = self
        if not msg:
            msg = lang.getstr("profile.choose")
        if ignore_current_profile:
            profile = None
        else:
            profile = get_current_profile(include_display_profile=True)
            if profile and not prefer_current_profile:
                dlg = ConfirmDialog(
                    self,
                    title=title,
                    msg=msg,
                    ok=lang.getstr("profile.current"),
                    cancel=lang.getstr("cancel"),
                    alt=lang.getstr("browse"),
                    bitmap=get_icon(32, APPNAME + "-profile-info"),
                )
                dlg.ok.SetDefault()
                result = dlg.ShowModal()
                if result == wx.ID_CANCEL:
                    return None
                if result != wx.ID_OK:
                    profile = None
        if profile:
            return profile
        default_dir, default_file = get_verified_path("last_icc_path")
        dlg = wx.FileDialog(
            parent,
            msg,
            defaultDir=default_dir,
            defaultFile=default_file,
            wildcard=lang.getstr("filetype.icc") + "|*.icc;*.icm",
            style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
        )
        dlg.Center(wx.BOTH)
        result = dlg.ShowModal()
        path = ""
        if result == wx.ID_OK:
            path = dlg.GetPath()
            setcfg("last_icc_path", path)
            setcfg("last_cal_or_icc_path", path)
        dlg.Destroy()
        if result != wx.ID_OK:
            return None
        try:
            profile = ICCProfile(path)
        except (OSError, ICCProfileInvalidError):
            InfoDialog(
                parent,
                msg=lang.getstr("profile.invalid") + "\n" + path,
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            return None
        if check_profile_class and (
            profile.profileClass != b"mntr" or profile.colorSpace != b"RGB"
        ):
            InfoDialog(
                parent,
                msg=lang.getstr(
                    "profile.unsupported",
                    (profile.profileClass, profile.colorSpace),
                )
                + "\n"
                + path,
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            return None
        return profile

    def measurement_report_create_handler(self, event: wx.Event) -> None:
        """Assign and initialize the report creation window.

        Args:
            event (wx.Event): The event that triggered the report creation.
        """
        if not getattr(self, "reportframe", None):
            self.init_reportframe()
        if self.reportframe.IsShownOnScreen():
            self.reportframe.Raise()
        else:
            self.reportframe.Show(not self.reportframe.IsShownOnScreen())

    def measurement_report_handler(
        self, event: wx.Event, path: None | str = None
    ) -> None:
        """Create a measurement report from the current measurement data.

        Args:
            event (wx.Event): The event that triggered the report creation.
            path (str, optional): Path to a CGATS file to use instead of the
                current measurement data. Defaults to None.
        """
        self_check_report = wx.GetKeyState(wx.WXK_ALT)

        if sys.platform == "darwin" or DEBUG:
            self.focus_handler(event)
        if not check_set_argyll_bin():
            return

        sim_ti3 = None
        sim_gray = None

        # select measurement data (ti1 or ti3)
        chart = getcfg("measurement_report.chart")
        try:
            chart = CGATS(chart, True)
        except (OSError, CGATSError) as exception:
            show_result_dialog(exception, getattr(self, "reportframe", self))
            return

        chart = self.worker.ensure_patch_sequence(chart, False)
        fields = getcfg("measurement_report.chart.fields")

        # profile(s)
        paths = []
        use_sim = getcfg("measurement_report.use_simulation_profile")
        use_sim_as_output = getcfg(
            "measurement_report.use_simulation_profile_as_output"
        )
        use_devlink = (
            getcfg("measurement_report.use_devlink_profile")
            # Use device link also if doing self check report
            # when 3D LUT for verification is enabled, because it
            # is the only way to apply the 3D LUT
            or (
                use_sim
                and use_sim_as_output
                and getcfg("3dlut.enable")
                and self_check_report
            )
        )
        # if not use_sim or not use_sim_as_output:
        # paths.append(getcfg("measurement_report.output_profile"))
        if use_sim:
            if use_sim_as_output and use_devlink:
                devlink_path = getcfg("measurement_report.devlink_profile")
                if devlink_path:
                    paths.append(devlink_path)
                else:
                    use_devlink = False
            paths.append(getcfg("measurement_report.simulation_profile"))
        sim_profile = None
        devlink = None
        oprof = profile = get_current_profile(True)
        for i, profilepath in enumerate(paths):
            try:
                profile = ICCProfile(profilepath)
            except (OSError, ICCProfileInvalidError) as exception:
                if isinstance(exception, ICCProfileInvalidError):
                    msg = "{}\n{}".format(lang.getstr("profile.invalid"), profilepath)
                else:
                    msg = str(exception)
                InfoDialog(
                    getattr(self, "reportframe", self),
                    msg=msg,
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                return
            else:
                if profile.version >= 4 and not profile.convert_iccv4_tags_to_iccv2():
                    msg = "\n".join(
                        [
                            lang.getstr("profile.iccv4.unsupported"),
                            profile.getDescription(),
                        ]
                    )
                    show_result_dialog(msg, self)
                    return
            if i in (0, 1) and use_sim:
                if use_sim_as_output and profile.colorSpace == b"RGB":
                    if i == 0 and use_devlink:
                        devlink = profile
                else:
                    if profile.colorSpace != b"RGB":
                        use_sim_as_output = False
                        devlink = None
                    sim_profile = profile
                    profile = oprof
        if not profile and not oprof:
            show_result_dialog(
                Error(
                    lang.getstr(
                        "display_profile.not_detected",
                        config.get_display_name(None, True),
                    )
                ),
                getattr(self, "reportframe", self),
            )
            return
        if not self.check_profile_b2a_hires(profile):
            return
        colormanaged = (
            use_sim
            and use_sim_as_output
            and not sim_profile
            and config.get_display_name(None, True) in ("madVR", "Prisma")
            and getcfg("3dlut.enable")
        )
        if DEBUG:
            for n, p in {
                "profile": profile,
                "devlink": devlink,
                "sim_profile": sim_profile,
                "oprof": oprof,
            }.items():
                if p:
                    print(n, p.getDescription())

        if use_sim:
            mprof = sim_profile if sim_profile else profile
        apply_map = (
            use_sim
            and mprof.colorSpace == b"RGB"
            and isinstance(mprof.tags.get("rXYZ"), XYZType)
            and isinstance(mprof.tags.get("gXYZ"), XYZType)
            and isinstance(mprof.tags.get("bXYZ"), XYZType)
            and not isinstance(mprof.tags.get("A2B0"), LUT16Type)
        )
        apply_off = apply_map and getcfg("measurement_report.apply_black_offset")
        apply_trc = apply_map and getcfg("measurement_report.apply_trc")
        bt1886 = None
        if apply_trc or apply_off:
            # TRC BT.1886-like, gamma with black offset, or just black offset
            try:
                odata = self.worker.xicclu(oprof, (0, 0, 0), pcs="x")
                if len(odata) != 1 or len(odata[0]) != 3:
                    raise ValueError(f"Blackpoint is invalid: {odata}")
            except Exception as exception:
                show_result_dialog(exception, getattr(self, "reportframe", self))
                return
            if odata[0][1]:
                # Got above zero blackpoint from lookup
                XYZbp = odata[0]  # noqa: N806
            else:
                # Got zero blackpoint from lookup.
                # Try chardata instead.
                XYZbp = oprof.get_chardata_bkpt()  # noqa: N806
                if XYZbp:
                    XYZbp = [v * XYZbp[1] for v in list(oprof.tags.wtpt.pcs.values())]  # noqa: N806
                else:
                    XYZbp = [0, 0, 0]  # noqa: N806
            if apply_trc:
                # TRC BT.1886-like
                gamma = getcfg("measurement_report.trc_gamma")
                gamma_type = getcfg("measurement_report.trc_gamma_type")
                outoffset = getcfg("measurement_report.trc_output_offset")
                if gamma_type == "b":
                    # Get technical gamma needed to achieve effective gamma
                    gamma = colormath.xicc_tech_gamma(gamma, XYZbp[1], outoffset)
            else:
                # Just black offset
                outoffset = 1.0
                gamma = 0.0
                for channel in "rgb":
                    gamma += mprof.tags[f"{channel}TRC"].get_gamma()
                gamma /= 3.0
            rXYZ = list(mprof.tags.rXYZ.values())  # noqa: N806
            gXYZ = list(mprof.tags.gXYZ.values())  # noqa: N806
            bXYZ = list(mprof.tags.bXYZ.values())  # noqa: N806
            mtx = colormath.Matrix3x3(
                [
                    [rXYZ[0], gXYZ[0], bXYZ[0]],
                    [rXYZ[1], gXYZ[1], bXYZ[1]],
                    [rXYZ[2], gXYZ[2], bXYZ[2]],
                ]
            )
            bt1886 = colormath.BT1886(mtx, XYZbp, outoffset, gamma, apply_trc)
            if apply_trc:
                # Make sure the profile has the expected Rec. 709 TRC
                # for BT.1886
                for _i, channel in enumerate(("r", "g", "b")):
                    if channel + "TRC" in mprof.tags:
                        mprof.tags[channel + "TRC"].set_trc(-709)
                # Set profile filename to None so it gets written to temp
                # directory (this makes sure we're actually using the changed
                # profile for lookup)
                mprof.filename = None

        if sim_profile:
            sim_intent = (
                "a" if getcfg("measurement_report.whitepoint.simulate") else "r"
            )
            void, sim_ti3, sim_gray = self.worker.chart_lookup(
                chart,
                sim_profile,
                check_missing_fields=True,
                intent=sim_intent,
                bt1886=bt1886,
            )
            # NOTE: we ignore the ti1 and gray patches here
            # only the ti3 is valuable at this point
            if not sim_ti3:
                return
            intent = (
                "r"
                if sim_intent == "r"
                or getcfg("measurement_report.whitepoint.simulate.relative")
                else "a"
            )
            bt1886 = None
        else:
            sim_intent = None
            intent = "r"
            if fields in ("LAB", "XYZ"):
                if getcfg("measurement_report.whitepoint.simulate"):
                    sim_intent = "a"
                    if not getcfg("measurement_report.whitepoint.simulate.relative"):
                        intent = "a"
                else:
                    chart.fix_device_values_scaling()
                    chart.adapt(cat=profile.guess_cat() or "Bradford")

        # lookup test patches
        ti1, ti3_ref, gray = self.worker.chart_lookup(
            sim_ti3 or chart,
            profile,
            bool(sim_ti3) or fields in ("LAB", "XYZ"),
            fields=None if bool(sim_ti3) else fields,
            intent=intent,
            bt1886=bt1886,
        )
        if not ti3_ref:
            return
        if not gray and sim_gray:
            gray = sim_gray

        if devlink:
            void, ti1, void = self.worker.chart_lookup(
                ti1,
                devlink,
                check_missing_fields=True,
                white_patches=1,
                white_patches_total=False,
            )
            if not ti1:
                return

        # let the user choose a location for the result
        report_type = "Self Check" if self_check_report else "Measurement"
        default_file = "{} Report {} - {} - {}".format(
            report_type,
            VERSION_STRING,
            re.sub(
                r"[\\/:;*?\"<>|]+",
                "_",
                self.display_ctrl.GetStringSelection().replace(
                    f" {lang.getstr('display.primary')}", ""
                ),
            ),
            strftime("%Y-%m-%d %H-%M.html"),
        )
        if not path:
            default_dir = get_verified_path(
                None, os.path.join(getcfg("profile.save_path"), default_file)
            )[0]
            dlg = wx.FileDialog(
                getattr(self, "reportframe", self),
                lang.getstr("save_as"),
                default_dir,
                default_file,
                wildcard=f"{lang.getstr('filetype.html')}|*.html;*.htm",
                style=wx.SAVE | wx.FD_OVERWRITE_PROMPT,
            )
            dlg.Center(wx.BOTH)
            result = dlg.ShowModal()
            if result == wx.ID_OK:
                path = make_argyll_compatible_path(dlg.GetPath())
                if not waccess(path, os.W_OK):
                    show_result_dialog(
                        Error(lang.getstr("error.access_denied.write", path)),
                        getattr(self, "reportframe", self),
                    )
                    return
            dlg.Destroy()
            if result != wx.ID_OK:
                return
        else:
            path = make_argyll_compatible_path(path)
        save_path = f"{os.path.splitext(path)[0]}.html"
        setcfg("last_filedialog_path", save_path)
        # check if file(s) already exist
        if os.path.exists(save_path):
            dlg = ConfirmDialog(
                getattr(self, "reportframe", self),
                msg=lang.getstr("dialog.confirm_overwrite", save_path),
                ok=lang.getstr("overwrite"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-warning"),
            )
            result = dlg.ShowModal()
            dlg.Destroy()
            if result != wx.ID_OK:
                return

        if self_check_report and oprof:
            # Instead of doing measurements, lookup ti1 through display profile

            # setup temp dir
            temp = self.worker.create_tempdir()
            if isinstance(temp, Exception):
                show_result_dialog(temp, getattr(self, "reportframe", self))
                return

            # filenames
            name, ext = os.path.splitext(os.path.basename(save_path))
            ti3_path = os.path.join(temp, f"{name}.ti3")
            profile_path = os.path.join(temp, f"{name}.icc")

            # Argyll applycal can't deal with single gamma TRC tags
            # or TRC tags with less than 256 entries
            _applycal_bug_workaround(oprof)

            # write profile to temp dir
            oprof.write(profile_path)

            # Check if we need to apply calibration
            if devlink and "-a" in parse_argument_string(
                devlink.tags.get("meta", {})
                .get("collink.args", {})
                .get("value", "-a" if getcfg("3dlut.output.profile.apply_cal") else "")
            ):
                oprof_cal_path = os.path.join(temp, f"{name}.cal")
                extract_cal_from_profile(oprof, oprof_cal_path)
                profile_with_cal_path = os.path.join(temp, f"{name}_with_cal.icc")

                applycal = get_argyll_util("applycal")
                if not applycal:
                    show_result_dialog(
                        Error(lang.getstr("argyll.util.not_found", "applycal")), self
                    )
                    return
                print(lang.getstr("apply_cal"))
                result = self.worker.exec_cmd(
                    applycal,
                    ["-v", oprof_cal_path, profile_path, profile_with_cal_path],
                    capture_output=True,
                    skip_scripts=True,
                )
                if not result:
                    result = Error(
                        "\n\n".join(
                            [
                                lang.getstr("apply_cal.error"),
                                "\n".join(self.worker.errors),
                            ]
                        )
                    )
                if isinstance(result, Exception) and not getcfg("dry_run"):
                    show_result_dialog(result, self)
                    return
                odesc = oprof.getDescription()
                oprof = ICCProfile(profile_with_cal_path)
                # Restore original description
                oprof.setDescription(odesc)

            void, ti3, void = self.worker.chart_lookup(
                ti1, oprof, pcs="x", intent="a", white_patches=0
            )
            wtpt = list(oprof.tags.wtpt.values())
            if isinstance(oprof.tags.get("lumi"), XYZType):
                luminance = oprof.tags.lumi.Y
            else:
                luminance = 100
            white_XYZ_cdm2 = [v * luminance for v in wtpt]  # noqa: N806
            ti3.add_keyword(
                "LUMINANCE_XYZ_CDM2", "{:.6f} {:.6f} {:.6f}".format(*white_XYZ_cdm2)
            )

            # write ti3 to temp dir
            try:
                with open(ti3_path, "wb") as ti3_file:
                    ti3_file.write(bytes(ti3))
            except OSError:
                InfoDialog(
                    getattr(self, "reportframe", self),
                    msg=lang.getstr("error.file.create", ti3_path),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                self.worker.wrapup(False)
                return

            print("-" * 80)
            print(lang.getstr("self_check_report"))
            self.measurement_report_consumer(
                True,
                ti3_path,
                profile,
                sim_profile,
                intent,
                sim_intent,
                devlink,
                ti3_ref,
                sim_ti3,
                save_path,
                chart,
                gray,
                apply_trc,
                use_sim,
                use_sim_as_output,
                oprof,
                True,
            )
            return

        # setup for measurement
        self.setup_measurement(
            self.measurement_report,
            ti1,
            oprof,
            profile,
            sim_profile,
            intent,
            sim_intent,
            devlink,
            ti3_ref,
            sim_ti3,
            save_path,
            chart,
            gray,
            apply_trc,
            colormanaged,
            use_sim,
            use_sim_as_output,
        )

    def measurement_report(
        self,
        ti1: CGATS,
        oprof: ICCProfile,
        profile: ICCProfile,
        sim_profile: None | ICCProfile,
        intent: str,
        sim_intent: None | str,
        devlink: None | ICCProfile,
        ti3_ref: CGATS,
        sim_ti3: None | CGATS,
        save_path: str,
        chart: CGATS,
        gray: list,
        apply_trc: bool,
        colormanaged: bool,
        use_sim: bool,
        use_sim_as_output: bool,
    ) -> None:
        """Start measurement report worker thread.

        Args:
            ti1 (CGATS): The CGATS data to measure.
            oprof (ICCProfile): The original profile used for the measurement.
            profile (ICCProfile): The profile used for the measurement.
            sim_profile (None | ICCProfile): The simulation profile used, if
                any.
            intent (str): Rendering intent used for the measurement.
            sim_intent (None | str): Simulation rendering intent, if any.
            devlink (None | ICCProfile): Device link profile used, if any.
            ti3_ref (CGATS): Reference CGATS data.
            sim_ti3 (None | CGATS): Simulation CGATS data, if any.
            save_path (str): Path to save the report.
            chart (CGATS): The chart used for the measurement.
            gray (list): Gray patches data.
            apply_trc (bool): Whether to apply TRC.
            colormanaged (bool): Whether the measurement is color managed.
            use_sim (bool): Whether to use simulation profile.
            use_sim_as_output (bool): Whether to use simulation profile as
                output.
        """
        print("-" * 80)
        progress_msg = lang.getstr("measurement_report")
        print(progress_msg)

        # setup temp dir
        temp = self.worker.create_tempdir()
        if isinstance(temp, Exception):
            show_result_dialog(temp, getattr(self, "reportframe", self))
            return

        # filenames
        name, ext = os.path.splitext(os.path.basename(save_path))
        ti1_path = os.path.join(temp, f"{name}.ti1")
        profile_path = os.path.join(temp, f"{name}.icc")

        debug_print(f"save_path: {save_path}")
        debug_print(f"name: {name}")
        debug_print(f"ext: {ext}")
        debug_print(f"ti1_path: {ti1_path}")
        debug_print(f"profile_path: {profile_path}")

        # write ti1 to temp dir
        try:
            with open(ti1_path, "wb") as ti1_file:
                ti1_file.write(bytes(ti1))
        except OSError:
            traceback.print_exc()
            InfoDialog(
                getattr(self, "reportframe", self),
                msg=lang.getstr("error.file.create", ti1_path),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            self.worker.wrapup(False)
            return

        # write profile to temp dir
        profile.write(profile_path)
        # Check if we need to apply calibration
        if not use_sim_as_output or (
            devlink
            and "-a"
            not in parse_argument_string(
                devlink.tags.get("meta", {})
                .get("collink.args", {})
                .get("value", "-a" if getcfg("3dlut.output.profile.apply_cal") else "")
            )
        ):
            calprof = oprof
        else:
            calprof = profile

        cal_path = os.path.join(temp, f"{name}.cal")
        try:
            # Extract calibration from profile
            cal = extract_cal_from_profile(calprof, cal_path, False)
        except Exception:
            traceback.print_exc()
            wx.CallAfter(
                show_result_dialog,
                Error(lang.getstr("cal_extraction_failed")),
                getattr(self, "reportframe", self),
            )
            self.Show()
            return
        if not cal:
            # Use linear calibration
            cal_path = get_data_path("linear.cal")

        # start readings
        self.worker.dispread_after_dispcal = False
        self.worker.interactive = config.get_display_name() == "Untethered"
        self.worker.start(
            self.measurement_report_consumer,
            self.worker.measure_ti1,
            cargs=(
                os.path.splitext(ti1_path)[0] + ".ti3",
                profile,
                sim_profile,
                intent,
                sim_intent,
                devlink,
                ti3_ref,
                sim_ti3,
                save_path,
                chart,
                gray,
                apply_trc,
                use_sim,
                use_sim_as_output,
                oprof,
            ),
            wargs=(ti1_path, cal_path, colormanaged),
            progress_msg=progress_msg,
            pauseable=True,
        )

    def measurement_report_consumer(
        self,
        result: bool | Exception,
        ti3_path: str,
        profile: ICCProfile,
        sim_profile: None | ICCProfile,
        intent: str,
        sim_intent: None | str,
        devlink: None | ICCProfile,
        ti3_ref: CGATS,
        sim_ti3: None | CGATS,
        save_path: str,
        chart: CGATS,
        gray: list,
        apply_trc: bool,
        use_sim: bool,
        use_sim_as_output: bool,
        oprof: ICCProfile,
        self_check_report: bool = False,
    ) -> None:
        """Consumer for measurement report worker thread.

        Args:
            result (bool | Exception): Result of the measurement.
            ti3_path (str): Path to the resulting ti3 file.
            profile (ICCProfile): The profile used for the measurement.
            sim_profile (None | ICCProfile): The simulation profile used, if any.
            intent (str): Rendering intent used for the measurement.
            sim_intent (None | str): Simulation rendering intent, if any.
            devlink (None | ICCProfile): Device link profile used, if any.
            ti3_ref (CGATS): Reference CGATS data.
            sim_ti3 (None | CGATS): Simulation CGATS data, if any.
            save_path (str): Path to save the report.
            chart (CGATS): The chart used for the measurement.
            gray (list): Gray patches data.
            apply_trc (bool): Whether to apply TRC.
            use_sim (bool): Whether to use simulation profile.
            use_sim_as_output (bool): Whether to use simulation profile as output.
            oprof (ICCProfile): Original profile used for the measurement.
            self_check_report (bool): Whether this is a self-check report.
        """
        self.Show()

        if not isinstance(result, Exception) and result:
            # get item 0 of the ti3 to strip the CAL part from the measured data
            try:
                ti3_measured = CGATS(ti3_path)[0]
            except (
                OSError,
                CGATSInvalidError,
                CGATSInvalidOperationError,
                CGATSKeyError,
                CGATSTypeError,
                CGATSValueError,
            ) as exc:
                result = exc
            else:
                print(lang.getstr("success"))
                result = self.measurement_file_check_confirm(ti3_measured)

        if isinstance(result, Exception) or not result:
            if isinstance(result, Exception):
                wx.CallAfter(
                    show_result_dialog, result, getattr(self, "reportframe", self)
                )

            # cleanup
            self.worker.wrapup(False if not isinstance(result, Exception) else result)
            return

        # Determine quantization
        qbits = None
        if config.get_display_name() != "Untethered":
            args = []
            if getcfg("extra_args.dispread").strip():
                args += parse_argument_string(getcfg("extra_args.dispread"))
            self.worker.add_measurement_features(
                args, True, allow_video_levels=True, quantize=True
            )
            quantize_arg = get_arg("-Z", args)
            if quantize_arg:
                try:
                    if quantize_arg[1] == "-Z":
                        # Next arg is quantization bit depth
                        qbits = int(args[quantize_arg[0] + 1])
                    else:
                        # Quantization bit depth is part of arg string
                        qbits = int(quantize_arg[1][2:])
                except (IndexError, TypeError, ValueError):
                    pass
            elif "-E" in args:
                qbits = 8  # ArgyllCMS default for video encoding (see dispread doc)
        if qbits:
            print(f"Quantizing reference device values to {qbits:.0f} bits")
            ti3_ref.quantize_device_values(qbits)
            if gray:
                qmax = 2**qbits - 1.0
                gray = [
                    [round(round(v / 100.0 * qmax) / qmax * 100.0, 4) for v in RGB]
                    for RGB in gray
                ]

        # Keep around ref TI3 for diagnostic purposes
        ti3_ref.write(f"{os.path.splitext(ti3_path)[0]}_ref.ti3")

        # Account for additional white patches
        white_rgb = {"RGB_R": 100, "RGB_G": 100, "RGB_B": 100}
        white_ref = ti3_ref.queryi(white_rgb)
        if devlink:
            # Remove additional white patch (device white = 100 before
            # accounting for effect of devicelink)
            # This is always the first patch ONLY
            ti3_measured.DATA.remove(0)
            # The new offset is the difference in length between measured and
            # ref because the white patch is always added at the start
            offset = len(ti3_measured.DATA) - len(ti3_ref.DATA)
            # Set full white RGB to 100
            for i in range(offset):
                for label in ("RGB_R", "RGB_G", "RGB_B"):
                    ti3_measured.DATA[i][label] = 100.0
            # Restore original device values
            for i in ti3_ref.DATA:
                for label in ("RGB_R", "RGB_G", "RGB_B"):
                    ti3_measured.DATA[i + offset][label] = ti3_ref.DATA[i][label]
            # White patches (device white = 100 after accounting for effect of
            # devicelink)
            white_measured = ti3_measured.queryi(white_rgb)
            # Update white cd/m2
            luminance = float(ti3_measured.LUMINANCE_XYZ_CDM2.split()[1])
            white_XYZ_cdm2 = [0, 0, 0]  # noqa: N806
            for i, label in enumerate(("XYZ_X", "XYZ_Y", "XYZ_Z")):
                white_XYZ_cdm2[i] = white_measured[0][label] * luminance / 100.0
            ti3_measured.LUMINANCE_XYZ_CDM2 = "{:.6f} {:.6f} {:.6f}".format(
                *white_XYZ_cdm2
            )
            # Scale to actual white Y after accounting for effect of devicelink
            scale = 100.0 / white_measured[0]["XYZ_Y"]
            for i in ti3_measured.DATA:
                for label in ("XYZ_X", "XYZ_Y", "XYZ_Z"):
                    ti3_measured.DATA[i][label] *= scale
        else:
            white_measured = ti3_measured.queryi(white_rgb)
            offset = max(len(white_measured) - len(white_ref), 0)

        # If patches were removed from the measured TI3, we need to remove them
        # from reference and simulation TI3
        if isinstance(result, tuple):
            ref_removed = []
            sim_removed = []
            for item in reversed(result[0]):
                key = item.key - offset
                ref_removed.insert(0, ti3_ref.DATA.pop(key))
                if sim_ti3:
                    sim_removed.insert(0, sim_ti3.DATA.pop(key))
            for item in ref_removed:
                print(f"Removed patch #{item.key:.0f} from reference TI3: {item}")
            for item in sim_removed:
                print(f"Removed patch #{item.key} from simulation TI3: {item}")
            # Update offset
            white_ref = ti3_ref.queryi(white_rgb)
            offset = max(len(white_measured) - len(white_ref), 0)

        # Determine if we should use planckian locus for assumed target wp
        # Detection will only work for profiles created by DisplayCAL
        planckian = False
        if (profile.tags.get("CIED", "") or profile.tags.get("targ", ""))[
            0:4
        ] == "CTI3":
            options_dispcal = get_options_from_profile(profile)[0]
            for option in options_dispcal:
                if option.startswith("T"):
                    planckian = True
                    break

        # calculate amount of calibration grayscale tone values
        cal_entrycount = 256
        if isinstance(profile.tags.get("vcgt"), VideoCardGammaType):
            rgb = [[], [], []]
            vcgt = profile.tags.vcgt
            if "data" in vcgt:
                # table
                cal_entrycount = vcgt["entryCount"]
                for i in range(cal_entrycount):
                    for j in range(3):
                        rgb[j].append(
                            float(vcgt["data"][j][i])
                            / (math.pow(256, vcgt["entrySize"]) - 1)
                            * 255
                        )
            else:
                # formula
                step = 100.0 / 255.0
                for i in range(cal_entrycount):
                    # float2dec(v) fixes miniscule deviations in the calculated gamma
                    for j, name in enumerate(("red", "green", "blue")):
                        vmin = float2dec(vcgt[name + "Min"] * 255)
                        v = float2dec(math.pow(step * i / 100.0, vcgt[name + "Gamma"]))
                        vmax = float2dec(vcgt[name + "Max"] * 255)
                        rgb[j].append(float2dec(vmin + v * (vmax - vmin), 8))
            cal_rgblevels = [len({round(n) for n in channel}) for channel in rgb]
        else:
            # Assume linear with all steps
            cal_rgblevels = [256, 256, 256]

        if not chart.filename.lower().endswith(".ti1") or sim_ti3:
            # make the device values match
            for i in ti3_ref.DATA:
                for color in ("RGB_R", "RGB_G", "RGB_B"):
                    if sim_ti3 and sim_ti3.DATA[i].get(color) is not None:
                        ti3_ref.DATA[i][color] = sim_ti3.DATA[i][color]
                    else:
                        ti3_ref.DATA[i][color] = ti3_measured.DATA[i + offset][color]

        cat = "Bradford"

        # create a 'joined' ti3 from ref ti3, with XYZ values from measured ti3
        # this makes sure CMYK data in the original ref will be present in
        # the newly joined ti3
        ti3_joined = CGATS(bytes(ti3_ref))[0]
        ti3_joined.LUMINANCE_XYZ_CDM2 = ti3_measured.LUMINANCE_XYZ_CDM2
        # add XYZ to DATA_FORMAT if not yet present
        labels_xyz = ("XYZ_X", "XYZ_Y", "XYZ_Z")
        if (
            "XYZ_X" not in list(ti3_joined.DATA_FORMAT.values())
            and "XYZ_Y" not in list(ti3_joined.DATA_FORMAT.values())
            and "XYZ_Z" not in list(ti3_joined.DATA_FORMAT.values())
        ):
            ti3_joined.DATA_FORMAT.add_data(labels_xyz)
        # set XYZ in joined ti3 to XYZ of measurements
        for i in ti3_joined.DATA:
            for color in labels_xyz:
                ti3_joined.DATA[i][color] = ti3_measured.DATA[i + offset][color]

        # cleanup
        self.worker.wrapup(False if not isinstance(result, Exception) else result)

        wtpt_profile_norm = tuple(n * 100 for n in list(profile.tags.wtpt.values()))
        if isinstance(profile.tags.get("chad"), ChromaticAdaptionTag):
            # undo chromatic adaption of profile whitepoint
            WX, WY, WZ = profile.tags.chad.inverted() * wtpt_profile_norm  # noqa: N806
            wtpt_profile_norm = tuple((n / WY) * 100.0 for n in (WX, WY, WZ))
            # guess chromatic adaption transform (Bradford, CAT02...)
            cat = profile.guess_cat() or cat
        elif isinstance(profile.tags.get("arts"), ChromaticAdaptionTag):
            cat = profile.guess_cat() or cat
        if oprof and isinstance(oprof.tags.get("lumi"), XYZType):
            # calculate unscaled whitepoint
            scale = oprof.tags.lumi.Y / 100.0
            wtpt_profile = tuple(n * scale for n in wtpt_profile_norm)
        else:
            wtpt_profile = wtpt_profile_norm

        if sim_profile:
            wtpt_sim_profile_norm = tuple(
                n * 100 for n in list(sim_profile.tags.wtpt.values())
            )
            if "chad" in sim_profile.tags:
                # undo chromatic adaption of profile whitepoint
                WX, WY, WZ = sim_profile.tags.chad.inverted() * wtpt_sim_profile_norm  # noqa: N806
                wtpt_sim_profile_norm = tuple((n / WY) * 100.0 for n in (WX, WY, WZ))

        wtpt_measured = tuple(float(n) for n in ti3_joined.LUMINANCE_XYZ_CDM2.split())
        # normalize so that Y = 100
        wtpt_measured_norm = tuple((n / wtpt_measured[1]) * 100 for n in wtpt_measured)

        if intent != "a" and sim_intent != "a":
            white = ti3_joined.queryi(white_rgb)
            for i in white:
                white[i].update(
                    {
                        "XYZ_X": wtpt_measured_norm[0],
                        "XYZ_Y": wtpt_measured_norm[1],
                        "XYZ_Z": wtpt_measured_norm[2],
                    }
                )

        black = ti3_joined.queryi1({"RGB_R": 0, "RGB_G": 0, "RGB_B": 0})
        if black:
            bkpt_measured_norm = black["XYZ_X"], black["XYZ_Y"], black["XYZ_Z"]
            if self_check_report and not bkpt_measured_norm[1]:
                XYZbp = oprof.get_chardata_bkpt(True)  # noqa: N806
                if XYZbp:
                    bkpt_measured_norm = tuple(v * 100 for v in XYZbp)
            bkpt_measured = tuple(
                wtpt_measured[1] / 100 * n for n in bkpt_measured_norm
            )
        else:
            bkpt_measured_norm = None
            bkpt_measured = None

        # set Lab values
        labels_Lab = ("LAB_L", "LAB_A", "LAB_B")  # noqa: N806
        for data in (ti3_ref, ti3_joined):
            data_formats = list(data.DATA_FORMAT.values())
            if (
                b"XYZ_X" in data_formats
                and b"XYZ_Y" in data_formats
                and b"XYZ_Z" in data_formats
            ):
                if (
                    b"LAB_L" not in data_formats
                    and b"LAB_A" not in data_formats
                    and b"LAB_B" not in data_formats
                ):
                    # add Lab fields to DATA_FORMAT if not present
                    data.DATA_FORMAT.add_data(labels_Lab)
                    has_Lab = False  # noqa: N806
                else:
                    has_Lab = True  # noqa: N806
                if data is ti3_joined or not has_Lab:
                    for i in data.DATA:
                        X, Y, Z = [data.DATA[i][color] for color in labels_xyz]  # noqa: N806
                        if data is ti3_joined:
                            # we need to adapt the measured values to D50
                            # print X, Y, Z, '->',
                            X, Y, Z = colormath.adapt(  # noqa: N806
                                X, Y, Z, wtpt_measured_norm, cat=cat
                            )
                            # print X, Y, Z
                        Lab = XYZ2Lab(X, Y, Z)  # noqa: N806
                        for j, color in enumerate(labels_Lab):
                            data.DATA[i][color] = Lab[j]
            if data is ti3_ref and sim_intent == "a" and intent == "a":
                for i in data.DATA:
                    # we need to adapt the reference values to D50
                    L, a, b = [data.DATA[i][color] for color in labels_Lab]  # noqa: N806
                    X, Y, Z = colormath.Lab2XYZ(L, a, b, scale=100)  # noqa: N806
                    # print X, Y, Z, '->',
                    X, Y, Z = colormath.adapt(X, Y, Z, wtpt_profile_norm, cat=cat)  # noqa: N806
                    # print X, Y, Z
                    Lab = XYZ2Lab(X, Y, Z)  # noqa: N806
                    for j, color in enumerate(labels_Lab):
                        data.DATA[i][color] = Lab[j]

        # gather data for report

        instrument = self.comport_ctrl.GetStringSelection()
        measurement_mode = self.measurement_mode_ctrl.GetStringSelection()
        instrument += " \u2014 " + measurement_mode

        observer = get_cfg_option_from_args(
            "observer", "-Q", self.worker.options_dispread
        )
        if observer != DEFAULTS["observer"]:
            instrument += " \u2014 " + self.observers_ab.get(observer, observer)

        ccmx = "None"
        reference_observer = None
        if not self_check_report and self.worker.instrument_can_use_ccxx():
            ccmx = getcfg("colorimeter_correction_matrix_file").split(":", 1)
            if len(ccmx) > 1 and ccmx[1]:
                ccmxpath = ccmx[1]
                ccmx = os.path.basename(ccmx[1])
                try:
                    cgats = CGATS(ccmxpath)
                except (OSError, CGATSError) as exception:
                    print(f"{ccmxpath}:", exception)
                else:
                    filename, ext = os.path.splitext(ccmx)
                    desc = cgats.get_descriptor()
                    desc = lang.getstr(
                        ext[1:] + "." + filename, default=desc.decode("utf-8")
                    )
                    # If the description is not the same as the 'sane'
                    # filename, add the filename after the description
                    # (max 31 chars)
                    # See also colorimeter_correction_check_overwite, the
                    # way the filename is processed must be the same
                    argyll_compatible_path = make_argyll_compatible_path(desc)
                    if (
                        re.sub(r"[\\/:;*?\"<>|]+", "_", argyll_compatible_path)
                        != filename
                    ):
                        ccmx = "{} &amp;lt;{}&amp;gt;".format(
                            desc,
                            ellipsis_(ccmx, 31, "m"),
                        )
                    if cgats.get(0, cgats).type == "CCMX":
                        reference_observer = cgats.queryv1("REFERENCE_OBSERVER")
                        if (
                            reference_observer
                            and reference_observer != DEFAULTS["observer"]
                        ):
                            reference_observer = self.observers_ab.get(
                                reference_observer, reference_observer
                            )
                            if reference_observer.lower() not in ccmx.lower():
                                ccmx += " \u2014 " + reference_observer
            else:
                ccmx = "None"

        if not sim_profile and use_sim and use_sim_as_output:
            sim_profile = profile

        if (
            getcfg("measurement_report.trc_gamma") != 2.4
            or getcfg("measurement_report.trc_gamma_type") != "B"
            or getcfg("measurement_report.trc_output_offset")
        ):
            trc = ""
        else:
            trc = "BT.1886"

        if self_check_report:
            display = oprof.getDeviceModelDescription() or "N/A"
            if oprof is not profile:
                display += f" (Profile: {oprof.getDescription()})"
            instrument = "N/A"
            ccmx = "N/A"
            report_type = "Self Check"
        else:
            display = self.display_ctrl.GetStringSelection().replace(
                " " + lang.getstr("display.primary"), ""
            )
            report_type = "Measurement"
        placeholders2data = {
            "${PLANCKIAN}": 'checked="checked"' if planckian else "",
            "${DISPLAY}": display,
            "${INSTRUMENT}": instrument,
            "${CORRECTION_MATRIX}": ccmx,
            "${BLACKPOINT}": "{:f} {:f} {:f}".format(
                *(bkpt_measured if bkpt_measured else (-1.0, -1.0, -1.0))
            ),
            "${WHITEPOINT}": "{:f} {:f} {:f}".format(*wtpt_measured),
            "${WHITEPOINT_NORMALIZED}": "{:f} {:f} {:f}".format(*wtpt_measured_norm),
            "${PROFILE}": profile.getDescription(),
            "${PROFILE_WHITEPOINT}": "{:f} {:f} {:f}".format(*wtpt_profile),
            "${PROFILE_WHITEPOINT_NORMALIZED}": "{:f} {:f} {:f}".format(
                *wtpt_profile_norm
            ),
            "${SIMULATION_PROFILE}": (
                sim_profile.getDescription() if sim_profile else ""
            ),
            "${TRC_GAMMA}": str(
                getcfg("measurement_report.trc_gamma") if apply_trc else "null"
            ),
            "${TRC_GAMMA_TYPE}": str(
                getcfg("measurement_report.trc_gamma_type") if apply_trc else ""
            ),
            "${TRC_OUTPUT_OFFSET}": str(
                getcfg("measurement_report.trc_output_offset") if apply_trc else 0
            ),
            "${TRC}": trc if apply_trc else "",
            "${WHITEPOINT_SIMULATION}": str(sim_intent == "a").lower(),
            "${WHITEPOINT_SIMULATION_RELATIVE}": str(
                sim_intent == "a" and intent == "r"
            ).lower(),
            "${DEVICELINK_PROFILE}": devlink.getDescription() if devlink else "",
            "${TESTCHART}": os.path.basename(chart.filename),
            "${ADAPTION}": str(profile.guess_cat(False) or cat),
            "${DATETIME}": strftime("%Y-%m-%d %H:%M:%S"),
            "${REF}": bytes(ti3_ref).decode(ENC, "replace").replace('"', "&quot;"),
            "${MEASURED}": bytes(ti3_joined)
            .decode(ENC, "replace")
            .replace('"', "&quot;"),
            "${CAL_ENTRYCOUNT}": str(cal_entrycount),
            "${CAL_RGBLEVELS}": repr(cal_rgblevels),
            "${GRAYSCALE}": repr(gray) if gray else "null",
            "${REPORT_VERSION}": VERSION_STRING,
            "${REPORT_TYPE}": report_type,
        }

        # create report
        try:
            report.create(save_path, placeholders2data, getcfg("report.pack_js"))
        except OSError as exception:
            show_result_dialog(exception, self)
        else:
            # show report
            wx.CallAfter(launch_file, save_path)

    def load_cal(self, cal: None | str = None, silent: bool = False) -> bool:
        """Load a calibration from a .cal file or ICC profile.

        Defaults to currently configured file if cal parameter is not given.

        Args:
            cal (str, optional): The path to the calibration file or ICC
                profile. Defaults to None, which uses the configured
                calibration file.
            silent (bool, optional): If True, suppresses output messages.
                Defaults to False.

        Returns:
            bool: True if the calibration was loaded successfully, False
                otherwise.
        """
        load_vcgt = getcfg("calibration.autoload") or cal
        if not cal:
            cal = getcfg("calibration.file", False)
        if not cal or not check_set_argyll_bin():
            return False
        if load_vcgt:
            verbose_print(lang.getstr("calibration.loading"))
            verbose_print(cal)
        if (
            not load_vcgt
            or self.install_cal(
                capture_output=True,
                cal=cal,
                skip_scripts=True,
                silent=silent,
                title=lang.getstr("calibration.load_from_cal_or_profile"),
            )
            is True
        ):
            if cal.lower().endswith(".icc") or cal.lower().endswith(".icm"):
                try:
                    profile = ICCProfile(cal)
                except (OSError, ICCProfileInvalidError) as exception:
                    print(exception)
                    profile = None
            else:
                profile = cal_to_fake_profile(cal)
            self.lut_viewer_load_lut(profile=profile)
            if silent and load_vcgt:
                verbose_print(lang.getstr("success"))
            return True
        if load_vcgt:
            verbose_print(lang.getstr("failure"))
        return False

    def reset_cal(self, event: wx.Event = None) -> bool:
        """Reset video card gamma table to linear.

        Args:
            event (wx.Event, optional): The event object. Defaults to None.

        Returns:
            bool: True if the calibration was reset successfully, False
                otherwise.
        """
        if not check_set_argyll_bin():
            return False
        verbose_print(lang.getstr("calibration.resetting"))
        if (
            self.install_cal(
                capture_output=True,
                cal=False,
                skip_scripts=True,
                silent=not (getcfg("dry_run") and event),
                title=lang.getstr("calibration.reset"),
            )
            is True
        ):
            profile = ICCProfile()
            profile._data = "\0" * 128
            profile._tags.desc = TextDescriptionType("", "desc")
            profile._tags.vcgt = VideoCardGammaTableType("", "vcgt")
            profile._tags.vcgt.update(
                {
                    "channels": 3,
                    "entryCount": 256,
                    "entrySize": 1,
                    "data": [
                        list(range(256)),
                        list(range(256)),
                        list(range(256)),
                    ],
                }
            )
            profile.size = len(profile.data)
            profile.is_loaded = True
            self.lut_viewer_load_lut(profile=profile)
            verbose_print(lang.getstr("success"))
            return True
        if not getcfg("dry_run"):
            verbose_print(lang.getstr("failure"))
        return False

    def load_display_profile_cal(
        self, event: None | wx.Event = None, lut_viewer_load_lut: bool = True
    ) -> bool:
        """Load calibration (vcgt) from current display profile.

        Args:
            event (wx.Event, optional): The event object. Defaults to None.
            lut_viewer_load_lut (bool, optional): If True, load the LUT viewer
                with the loaded profile. Defaults to True.

        Returns:
            bool: True if the calibration was loaded successfully, False
                otherwise.
        """
        profile = get_display_profile()
        if not check_set_argyll_bin():
            return False
        if getcfg("calibration.autoload") or event:
            verbose_print(lang.getstr("calibration.loading_from_display_profile"))
            if profile and profile.filename:
                verbose_print(profile.filename)
        if (not getcfg("calibration.autoload") and not event) or self.install_cal(
            capture_output=True,
            cal=True,
            skip_scripts=True,
            silent=not (getcfg("dry_run") and event),
            title=lang.getstr("calibration.load_from_display_profile"),
        ) is True:
            if lut_viewer_load_lut:
                self.lut_viewer_load_lut(profile=profile)
            if getcfg("calibration.autoload") or event:
                verbose_print(lang.getstr("success"))
            return True
        if not getcfg("dry_run") and (getcfg("calibration.autoload") or event):
            verbose_print(lang.getstr("failure"))
        return False

    def report_calibrated_handler(self, event: wx.Event) -> None:
        """Report on calibrated display and exit.

        Args:
            event (wx.Event): The event object.
        """
        self.setup_measurement(self.report)

    def report_uncalibrated_handler(self, event: wx.Event) -> None:
        """Report on uncalibrated display and exit.

        Args:
            event (wx.Event): The event object.s
        """
        self.setup_measurement(self.report, False)

    def report(self, report_calibrated: bool = True) -> None:
        """Generate a report on the current display.

        Args:
            report_calibrated (bool): If True, generate a report for a
                calibrated display. If False, generate a report for an
                uncalibrated display.
        """
        if not check_set_argyll_bin():
            return
        if self.measure_auto(self.report, report_calibrated):
            return
        print("-" * 80)
        if report_calibrated:
            self.report_title = lang.getstr("report.calibrated")
        else:
            self.report_title = lang.getstr("report.uncalibrated")
        print(self.report_title)
        self.worker.interactive = False
        self.worker.start(
            self.result_consumer,
            self.worker.report,
            wkwargs={"report_calibrated": report_calibrated},
            progress_msg=self.report_title,
            pauseable=True,
            resume=bool(getattr(self, "measure_auto_after", None)),
        )

    def result_consumer(self, result: bool | Exception) -> None:
        """Generic result consumer.

        Shows an info window on success or an info/warn/error dialog if result
        was an exception.

        Args:
            result (bool | Exception): Result of the operation. If True, the
                operation was successful. If an Exception, the operation failed.
        """
        if isinstance(result, Exception) and result:
            wx.CallAfter(show_result_dialog, result, self)
        else:
            stream = FilteredStream(
                StringIO(),
                discard=self.worker.recent.discard,
                triggers=FilteredStream.triggers,
                prestrip=self.worker.recent.prestrip,
            )
            for line in self.worker.output:
                stream.write(line)
            stream.seek(0)
            wx.CallAfter(
                self.show_additional_infoframe,
                "".join([line for line in stream.readlines() if line.strip()]).strip(),
                self.report_title,
            )
        self.worker.wrapup(False)
        self.Show()

    def show_additional_infoframe(self, txt: str, title: None | str = None) -> None:
        """Show an additional info frame with the given text.

        Args:
            txt (str): The text to display in the info frame.
            title (None | str, optional): The title of the info frame. Defaults to
                None.
        """
        infoframe = LogWindow(self, title=title)
        infoframe.Unbind(wx.EVT_CLOSE)
        infoframe.Unbind(wx.EVT_MOVE)
        infoframe.Unbind(wx.EVT_SIZE)
        infoframe.Log(txt)
        wx.CallAfter(infoframe.Show)

    def calibrate_btn_handler(self, event: wx.Event) -> None:
        """Handle calibrate button click event.

        Args:
            event (wx.Event): The event object.
        """
        if sys.platform == "darwin" or DEBUG:
            self.focus_handler(event)
        if self.check_show_macos_bugs_warning(profile=False) is False:
            return
        if (
            not isinstance(event, CustomEvent)
            and not getcfg("profile.update")
            and (not getcfg("calibration.update") or is_profile())
            and getcfg("trc")
        ):
            update_profile = getcfg("calibration.update") and is_profile()
            if update_profile:
                msg = lang.getstr("calibration.update_profile_choice")
                ok = lang.getstr("profile.update")
            else:
                msg = lang.getstr("calibration.create_fast_matrix_shaper_choice")
                ok = lang.getstr("calibration.create_fast_matrix_shaper")
            dlg = ConfirmDialog(
                self,
                msg=msg,
                ok=ok,
                alt=lang.getstr("button.calibrate"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-question"),
            )
            result = dlg.ShowModal()
            dlg.Destroy()
            if result == wx.ID_CANCEL:
                return
            if update_profile and result == wx.ID_OK:
                setcfg("profile.update", 1)
        else:
            result = None
        self.worker.dispcal_create_fast_matrix_shaper = result == wx.ID_OK
        if (
            check_set_argyll_bin()
            and self.check_overwrite(".cal")
            and (
                (
                    not getcfg("profile.update")
                    and not self.worker.dispcal_create_fast_matrix_shaper
                )
                or self.check_overwrite(PROFILE_EXT)
            )
        ):
            self.setup_measurement(self.just_calibrate)

    def just_calibrate(self) -> None:
        """Just calibrate, optionally creating a fast matrix shaper profile."""
        if self.measure_auto(self.just_calibrate):
            return
        print("-" * 80)
        print(lang.getstr("button.calibrate"))
        setcfg("calibration.continue_next", 0)
        if getcfg("calibration.interactive_display_adjustment") and not getcfg(
            "calibration.update"
        ):
            # Interactive adjustment, do not show progress dialog
            self.worker.interactive = True
        else:
            # No interactive adjustment, show progress dialog
            self.worker.interactive = False
        self.worker.start_calibration(
            self.just_calibrate_finish,
            remove=True,
            progress_msg=lang.getstr("calibration"),
            resume=bool(getattr(self, "measure_auto_after", None)),
        )

    def just_calibrate_finish(self, result: bool | Exception) -> None:
        """Finish calibration.

        Args:
            result (bool | Exception): Result of the calibration process.
        """
        start_timers = True
        if not isinstance(result, Exception) and result:
            wx.CallAfter(self.update_calibration_file_ctrl)
            if getcfg("log.autoshow"):
                wx.CallAfter(self.infoframe_toggle_handler, show=True)
            if (
                getcfg("profile.update")
                or self.worker.dispcal_create_fast_matrix_shaper
            ):
                start_timers = False
                wx.CallAfter(
                    self.profile_finish,
                    True,
                    success_msg=lang.getstr("calibration.complete"),
                    install_3dlut=getcfg("3dlut.create"),
                )
            elif getcfg("trc"):
                wx.CallAfter(self.load_cal, silent=True)
                wx.CallAfter(
                    InfoDialog,
                    self,
                    msg=lang.getstr("calibration.complete"),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-information"),
                )
        elif isinstance(result, Exception):
            wx.CallAfter(show_result_dialog, result, self)
        elif not getcfg("dry_run"):
            wx.CallAfter(
                InfoDialog,
                self,
                msg=lang.getstr("calibration.incomplete"),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
        self.Show(start_timers=start_timers)

    def setup_measurement(
        self,
        pending_function: Callable,
        *pending_function_args: tuple,
        **pending_function_kwargs: dict,
    ) -> None:
        """Setup measurement.

        Args:
            pending_function (callable): Function to call after setup.
            *pending_function_args: Positional arguments for pending_function.
            **pending_function_kwargs: Keyword arguments for pending_function.
        """
        display_name = config.get_display_name(None, True)
        if display_name == "Web @ localhost" or display_name.startswith("Chromecast "):
            for name, patterngenerator in list(self.worker.patterngenerators.items()):
                if (
                    display_name.startswith("Chromecast ")
                    and ChromeCastPatternGenerator
                    and isinstance(patterngenerator, ChromeCastPatternGenerator)
                ):
                    # Chromecast uses a single client session; reset it so dispwin
                    # can establish a fresh connection for each run.
                    patterngenerator.disconnect_client()
                    self.worker.patterngenerators.pop(name)
                elif display_name == "Web @ localhost" and isinstance(
                    patterngenerator, WebWinHTTPPatternGeneratorServer
                ):
                    # Keep WebWin server running and reusable between setup/test
                    # and interactive adjustment steps to avoid reconnect churn.
                    continue
        elif not self.setup_patterngenerator(self):
            return
        writecfg()
        if pending_function_kwargs.get("wrapup", True):
            self.worker.wrapup(False)
        pending_function_kwargs.pop("wrapup", None)
        self.HideAll()
        self.set_pending_function(
            pending_function, *pending_function_args, **pending_function_kwargs
        )
        if (
            config.is_virtual_display()
            and display_name not in ("Resolve", "Prisma")
            and not display_name.startswith("Chromecast ")
            and not display_name.startswith("Prisma ")
        ) or getcfg("dry_run"):
            self.call_pending_function()
        elif (
            sys.platform in ("darwin", "win32")
            or ISEXE
            or self.worker._use_patternwindow
        ):
            # Preliminary Wayland support. This still needs a lot
            # of work as Argyll doesn't support Wayland natively yet,
            # so we use virtual display to drive our own patch window.
            self.measureframe.Show()
        else:
            wx.CallAfter(self.start_measureframe_subprocess)

    def setup_observer_ctrl(self) -> None:
        """Setup observer control.

        Choice of available observers varies with ArgyllCMS version.
        """
        self.observers_ab = {}
        for observer in config.VALID_VALUES["observer"]:
            self.observers_ab[observer] = lang.getstr("observer." + observer)
        self.observers_ba = swap_dict_keys_values(self.observers_ab)
        self.observer_ctrl.SetItems(list(self.observers_ab.values()))

    def setup_patterngenerator(
        self,
        parent: None | wx.Window = None,
        title: str = APPNAME,
        upload: bool = False,
    ) -> bool:
        """Setup pattern generator.

        Args:
            parent (None | wx.Window, optional): Parent window for dialogs.
            title (str, optional): Title for dialogs.
            upload (bool, optional): If True, show preset selection and
                filename for upload to Prisma.

        Returns:
            bool: True if successful, False if not.
        """
        if not parent:
            parent = self
        retval = True
        display_name = config.get_display_name(None, True)
        if display_name == "Prisma":
            # Ask for prisma hostname or IP
            dlg = ConfirmDialog(
                parent,
                title=title,
                msg=lang.getstr("patterngenerator.prisma.specify_host"),
                ok=lang.getstr("continue"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-question"),
            )
            host = getcfg("patterngenerator.prisma.host")
            dlg.host = wx.ComboBox(dlg, -1, host)

            def check_host_empty(event: wx.Event) -> None:
                """Check if the host input is empty and enable/disable the OK button.

                Args:
                    event (wx.Event): The event object.
                """
                dlg.ok.Enable(bool(dlg.host.GetValue()))

            dlg.host.Bind(wx.EVT_TEXT, check_host_empty)
            dlg.host.Bind(wx.EVT_COMBOBOX, check_host_empty)
            dlg.sizer3.Add(
                dlg.host, 0, flag=wx.TOP | wx.ALIGN_LEFT | wx.EXPAND, border=12
            )
            dlg.errormsg = wx.StaticText(dlg, -1, "")
            dlg.sizer3.Add(
                dlg.errormsg, 0, flag=wx.TOP | wx.ALIGN_LEFT | wx.EXPAND, border=6
            )
            if upload:
                # Show preset selection & filename
                sizer = wx.BoxSizer(wx.HORIZONTAL)
                dlg.sizer3.Add(
                    sizer, 0, flag=wx.TOP | wx.ALIGN_LEFT | wx.EXPAND, border=12
                )
                sizer.Add(
                    wx.StaticText(dlg, -1, lang.getstr("3dlut.holder.assign_preset")),
                    flag=wx.ALIGN_CENTER_VERTICAL,
                )
                preset = wx.Choice(
                    dlg,
                    -1,
                    choices=config.VALID_VALUES["patterngenerator.prisma.preset"],
                )
                preset.SetStringSelection(getcfg("patterngenerator.prisma.preset"))
                sizer.Add(preset, flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL, border=8)
                # Filename
                basename = os.path.basename(getcfg("3dlut.input.profile"))
                name = os.path.splitext(basename)[0]
                # Shorten long name
                gamut = {
                    "SMPTE_RP145_NTSC": "NTSC",
                    "EBU3213_PAL": "PAL",
                    "SMPTE431_P3": "P3",
                }.get(name, name)
                # Use file created date & time for filename
                filename = strftime(
                    f"{gamut}-%Y%m%dT%H%M%S.3dl",
                    localtime(os.stat(self.lut3d_path).st_ctime),
                )
                dlg.sizer3.Add(
                    wx.StaticText(
                        dlg,
                        -1,
                        "{}: {}".format(lang.getstr("filename.upload"), filename),
                    ),
                    flag=wx.TOP | wx.ALIGN_LEFT,
                    border=12,
                )
            dlg.sizer0.SetSizeHints(dlg)
            dlg.sizer0.Layout()

            def check_host(host: str) -> None:
                """Check if the given host is reachable.

                Args:
                    host (str): The host to check, either an IP address or a
                        hostname.
                """
                try:
                    ip = socket.gethostbyname(host)
                    self.worker.patterngenerator.host = ip
                    self.worker.patterngenerator.connect()
                except OSError as exception:
                    result = exception
                else:
                    result = ip
                wx.CallAfter(check_host_consumer, result)

            def check_host_consumer(result: str | Exception) -> None:
                """Consumer for check_host result.

                Args:
                    result (str | Exception): Result of the host check.
                        If successful, a string with the host IP address.
                        If an error occurred, an Exception.
                """
                if not dlg:
                    return
                if isinstance(result, Exception):
                    dlg.Freeze()
                    if isinstance(result, socket.gaierror):
                        dlg.errormsg.Label = lang.getstr("host.invalid.lookup_failed")
                    else:
                        width = dlg.errormsg.Size[0]
                        dlg.errormsg.Label = str(result)
                        dlg.errormsg.Wrap(width)
                    dlg.errormsg.ForegroundColour = wx.Colour(204, 0, 0)
                    dlg.ok.Enable()
                    dlg.sizer0.SetSizeHints(dlg)
                    dlg.sizer0.Layout()
                    dlg.Refresh()
                    dlg.Thaw()
                    wx.Bell()
                else:
                    dlg.EndModal(wx.ID_OK)

            def check_host_handler(event: wx.Event) -> None:
                host = dlg.host.GetValue()
                if host:
                    dlg.Freeze()
                    dlg.errormsg.Label = lang.getstr("please_wait")
                    dlg.errormsg.ForegroundColour = wx.SystemSettings.GetColour(
                        wx.SYS_COLOUR_WINDOWTEXT
                    )
                    dlg.ok.Disable()
                    dlg.sizer0.SetSizeHints(dlg)
                    dlg.sizer0.Layout()
                    dlg.Refresh()
                    dlg.Thaw()
                    thread = threading.Thread(
                        target=check_host,
                        name=f"PrismaPatternGenerator.CheckHost({host})",
                        args=(host,),
                    )
                    thread.start()
                else:
                    wx.Bell()

            def add_client(addr_client: tuple[str, dict]) -> None:
                """Add a discovered Prisma pattern generator client to the dialog.

                Args:
                    addr_client (tuple): A tuple containing the address and
                        client information. The first element is the address,
                        and the second element is a dictionary with client
                        information, including the 'name'.
                """
                if not dlg:
                    return
                name = addr_client[1]["name"]
                if sys.platform != "win32" and not name.endswith(".local"):
                    name += ".local"
                dlg.host.Append(name)
                if not dlg.host.GetValue():
                    dlg.host.SetSelection(0)
                    check_host_empty(None)

            def discover() -> None:
                """Discover Prisma pattern generator clients."""
                self.worker.patterngenerator.bind(
                    "on_client_added",
                    lambda addr_client: wx.CallAfter(add_client, addr_client),
                )
                self.worker.patterngenerator.listen()
                self.worker.patterngenerator.announce()

            thread = threading.Thread(
                target=discover, name="PrismaPatternGenerator.ClientDiscovery"
            )
            dlg.ok.Bind(wx.EVT_BUTTON, check_host_handler)
            dlg.ok.Enable(bool(host))
            if self.worker.patterngenerator:
                self.worker.patterngenerator.disconnect_client()
            else:
                self.worker.setup_patterngenerator()
            wx.CallAfter(thread.start)
            result = dlg.ShowModal()
            self.worker.patterngenerator.listening = False
            host = dlg.host.GetValue()
            if result == wx.ID_OK and upload:
                setcfg("patterngenerator.prisma.preset", preset.GetStringSelection())
                retval = filename
            dlg.Destroy()
            if result != wx.ID_OK or not host:
                return None
            setcfg("patterngenerator.prisma.host", host)
        elif display_name == "madVR":
            # Connect to madTPG (launch local instance under Windows)
            def closedlg(self, action: int = wx.ID_OK) -> None:  # noqa: D417, ANN001
                """Close the dialog if it exists.

                Args:
                    action (int): The action to take when closing the dialog.
                """
                dlg = getattr(self, "setup_patterngenerator_waitdialog", None)
                if dlg:
                    dlg.EndModal(action)
                self.setup_patterngenerator_waitdialog = None

            cancel_event = threading.Event()

            def connect(self) -> None:  # noqa: ANN001
                """Connect to madTPG and handle the connection result."""
                exception = None
                action = None
                try:
                    if not self.worker.madtpg_connect():
                        raise Error(lang.getstr("madtpg.launch.failure"))
                except Exception as e:
                    action = wx.ID_CLOSE
                    exception = e
                else:
                    action = wx.ID_OK
                finally:
                    if not cancel_event.is_set():
                        wx.CallAfter(closedlg, self, action)
                        if action != wx.ID_OK:
                            wx.CallAfter(show_result_dialog, exception, parent)

            thread = threading.Thread(
                target=connect, name="madTPG_Connect", args=(self,)
            )
            thread.start()
            sleep(0.2)
            if thread.is_alive():
                dlg = ConfirmDialog(
                    parent,
                    title=title,
                    msg=lang.getstr("please_wait"),
                    cancel=lang.getstr("cancel"),
                    bitmap=get_icon(32, "dialog-information"),
                )
                dlg.ok.Hide()
                dlg.sizer0.SetSizeHints(dlg)
                dlg.sizer0.Layout()
                self.setup_patterngenerator_waitdialog = dlg
                result = dlg.ShowModal()
                dlg.Destroy()
                if result == wx.ID_CANCEL:
                    cancel_event.set()
                    if hasattr(self.worker, "madtpg") and hasattr(
                        self.worker.madtpg, "shutdown"
                    ):
                        self.worker.madtpg.shutdown()
                    return None
                if result != wx.ID_OK:
                    # Error
                    return False
        elif display_name in ("Resolve", "Web @ localhost") or display_name.startswith(
            "Chromecast "
        ):
            logfile = LineCache(3)
            try:
                self.worker.setup_patterngenerator(logfile)
            except Exception as exception:
                show_result_dialog(exception, parent)
                return None
            if not hasattr(self.worker.patterngenerator, "conn"):
                # Wait for connection
                def closedlg(self) -> None:  # noqa: ANN001
                    """Close the dialog if it exists."""
                    dlg = getattr(self, "setup_patterngenerator_waitdialog", None)
                    if dlg:
                        dlg.EndModal(wx.ID_OK)
                    self.setup_patterngenerator_waitdialog = None

                def wait_for_connection(self) -> None:  # noqa: ANN001
                    """Wait for the pattern generator connection to be established."""
                    self.worker.patterngenerator.wait()
                    if hasattr(self.worker.patterngenerator, "conn"):
                        # Close dialog
                        wx.CallAfter(closedlg, self)

                threading.Thread(
                    target=wait_for_connection,
                    name="PatternGeneratorConnectionListener",
                    args=(self,),
                ).start()
                while not logfile.read():
                    sleep(0.1)
                dlg = ConfirmDialog(
                    parent,
                    title=title,
                    msg=logfile.read(),
                    cancel=lang.getstr("cancel"),
                    bitmap=get_icon(32, "dialog-information"),
                )
                dlg.ok.Hide()
                dlg.sizer0.SetSizeHints(dlg)
                dlg.sizer0.Layout()
                self.setup_patterngenerator_waitdialog = dlg
                result = dlg.ShowModal()
                dlg.Destroy()
                if result == wx.ID_CANCEL:
                    self.worker.patterngenerator.listening = False
                    return None
        elif (
            not config.is_uncalibratable_display()
            and not self.worker.has_lut_access()
            and not self.worker.has_separate_lut_access()
            and not self.worker._use_patternwindow
        ):
            show_result_dialog(Error(lang.getstr("lut_access.unsupported")), self)
            retval = False
        return retval

    def start_measureframe_subprocess(self) -> None:
        """Start the measureframe subprocess."""
        script = (
            "import sys;"
            f"sys.path.insert(0, {PYDIR!r});"
            "from DisplayCAL import wx_measure_frame;"
            "wx_measure_frame.main();"
            "sys.exit(wx_measure_frame.MeasureFrame.exitcode)"
        )
        args = [
            EXE,
            "-c",
            script,
        ]
        env = os.environ.copy()
        if wx.Display.GetCount() == 1 and len(self.worker.display_rects) > 1:
            # Separate X screens, TwinView or similar
            display = wx.Display(0)
            geometry = display.Geometry
            union = wx.Rect()
            xy = []
            for rect in self.worker.display_rects:
                if rect[:2] in xy or rect[2:] == geometry[2:]:
                    # Overlapping x y coordinates or screen filling whole
                    # reported geometry, so assume separate X screens
                    union = None
                    break
                xy.append(rect[:2])
                union = union.Union(rect)
            if union == geometry:
                # Assume TwinView or similar where Argyll enumerates 1+n
                # displays but wx only 'sees' one that is the union of them
                pass
            else:
                # Assume separate X screens
                display_no = getcfg("display.number") - 1
                x_hostname, x_display, x_screen = util_x.get_display()
                x_screen = display_no
                try:
                    from DisplayCAL import real_display_size_mm
                except ImportError as exception:
                    InfoDialog(
                        self,
                        msg=str(exception),
                        ok=lang.getstr("ok"),
                        bitmap=get_icon(32, "dialog-warning"),
                    )
                else:
                    display = real_display_size_mm.get_x_display(display_no)
                    if display:
                        x_hostname, x_display, x_screen = display
                env["DISPLAY"] = f"{x_hostname}:{x_display}.{x_screen}"
        delayedresult.startWorker(
            self.measureframe_consumer, self.measureframe_subprocess, wargs=(args, env)
        )

    def measureframe_subprocess(self, args: tuple, env: dict) -> tuple[int, str]:
        """Run the measureframe subprocess.

        Args:
            args (tuple): The command to run.
            env (dict): The environment variables to set.

        Returns:
            tuple[int, str]: A tuple containing the return code and stderr
                output.
        """
        returncode = -1
        try:
            p = sp.Popen(
                args,
                stdin=sp.PIPE,
                stdout=sp.PIPE,
                stderr=sp.PIPE,
                env=env,
            )
        except Exception as exception:
            stderr = safe_str(exception)
        else:
            self._measureframe_subprocess = p
            stdout, stderr = p.communicate()
            returncode = self._measureframe_subprocess.returncode
            del self._measureframe_subprocess
        return returncode, stderr

    def measureframe_consumer(
        self, delayed_result: delayedresult.DelayedResult
    ) -> None:
        """Consumer for the measureframe subprocess.

        Args:
            delayed_result (delayedResult.DelayedResult): The result of the
                measureframe subprocess.
        """
        returncode, stderr = delayed_result.get()
        if returncode != -1:
            config.initcfg()
            self.get_set_display()
        if returncode != 255:
            self.Show(start_timers=True)
            self.restore_measurement_mode()
            self.restore_testchart()
            if returncode != 0 and stderr and stderr.strip():
                InfoDialog(
                    self,
                    msg=str(stderr.strip()),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
        else:
            self.call_pending_function()

    def get_set_display(self, update_ccmx_items: bool = False) -> None:
        """Set the display selection to the configured display number.

        Args:
            update_ccmx_items (bool): If True, update the colorimeter
                correction matrix items in the UI.
        """
        debug_print("[D] get_set_display")
        if self.worker.displays:
            self.display_ctrl.SetSelection(
                min(
                    max(0, len(self.worker.displays) - 1),
                    max(0, getcfg("display.number") - 1),
                )
            )
        self.display_ctrl_handler(
            CustomEvent(wx.EVT_CHOICE.evtType[0], self.display_ctrl),
            load_lut=False,
            update_ccmx_items=update_ccmx_items,
        )

    def get_ccxx_measurement_modes(
        self,
        instrument_name: str,
        swap: bool = False,
    ) -> dict:
        """Get measurement modes suitable for colorimeter correction creation.

        Args:
            instrument_name (str): The name of the instrument.
            swap (bool): If True, swap the keys and values in the returned
                dictionary.
        """
        # IMPORTANT: Make changes aswell in the following locations:
        # - DisplayCAL.MainFrame.create_colorimeter_correction_handler
        # - DisplayCAL.MainFrame.set_ccxx_measurement_mode
        # - DisplayCAL.MainFrame.update_colorimeter_correction_matrix_ctrl_items
        # - worker.Worker.check_add_display_type_base_id
        # - worker.Worker.instrument_can_use_ccxx
        modes = {
            "ColorHug": {
                "F": lang.getstr("measurement_mode.factory"),
                "R": lang.getstr("measurement_mode.raw"),
            },
            "ColorHug2": {
                "F": lang.getstr("measurement_mode.factory"),
                "R": lang.getstr("measurement_mode.raw"),
            },
            "ColorMunki Smile": {"f": lang.getstr("measurement_mode.lcd.ccfl")},
            "Colorimtre HCFR": {"R": lang.getstr("measurement_mode.raw")},
            "K-10": {"F": lang.getstr("measurement_mode.factory")},
            "SpyderX": {
                "l": lang.getstr("measurement_mode.lcd"),
                "e": lang.getstr("measurement_mode.lcd.white_led"),
                "b": lang.getstr("measurement_mode.lcd.wide_gamut.led"),
                "i": lang.getstr("measurement_mode.lcd.wide_gamut.gb_led"),
            },
            "SpyderX2": {
                "l": lang.getstr("measurement_mode.lcd"),
                "e": lang.getstr("measurement_mode.lcd.white_led"),
                "b": lang.getstr("measurement_mode.lcd.wide_gamut.led"),
                "i": lang.getstr("measurement_mode.lcd.wide_gamut.gb_led"),
                "h": lang.getstr(
                    "measurement_mode.lcd.high_brightness", "High brightness"
                ),
            },
            "Spyder 2024": {
                "l": lang.getstr("measurement_mode.lcd"),
                "e": lang.getstr("measurement_mode.lcd.white_led"),
                "b": lang.getstr("measurement_mode.lcd.wide_gamut.led"),
                "i": lang.getstr("measurement_mode.lcd.wide_gamut.gb_led"),
                "h": lang.getstr(
                    "measurement_mode.lcd.high_brightness", "High brightness"
                ),
                "o": lang.getstr("measurement_mode.lcd.oled", "OLED"),
                "m": lang.getstr("measurement_mode.lcd.mini_led", "Mini-LED"),
            },
        }.get(
            instrument_name,
            {
                "c": lang.getstr("measurement_mode.refresh"),
                "l": lang.getstr("measurement_mode.lcd"),
            },
        )
        if swap:
            modes = swap_dict_keys_values(modes)
        return modes

    def set_ccxx_measurement_mode(self) -> None:
        """Set measurement mode suitable for colorimeter correction creation."""
        # IMPORTANT: Make changes aswell in the following locations:
        # - DisplayCAL.MainFrame.create_colorimeter_correction_handler
        # - DisplayCAL.MainFrame.get_ccxx_measurement_modes
        # - DisplayCAL.MainFrame.update_colorimeter_correction_matrix_ctrl_items
        # - worker.Worker.check_add_display_type_base_id
        # - worker.Worker.instrument_can_use_ccxx
        measurement_mode = None
        if getcfg("measurement_mode") == "auto":
            # Make changes in worker.Worker.add_instrument_features too!
            if self.worker.get_instrument_name() == "ColorHug":
                measurement_mode = "R"
            elif self.worker.get_instrument_name() == "ColorHug2":
                measurement_mode = "F"
            else:
                measurement_mode = "l"
        elif self.worker.get_instrument_name() in ("ColorHug", "ColorHug2") and getcfg(
            "measurement_mode"
        ) not in ("F", "R"):
            # Automatically set factory measurement mode if not already
            # factory or raw measurement mode
            measurement_mode = "F"
        elif (
            self.worker.get_instrument_name() == "ColorMunki Smile"
            and getcfg("measurement_mode") != "f"
        ):
            # Automatically set LCD measurement mode if not already
            # LCD CCFL measurement mode
            measurement_mode = "f"
        elif (
            self.worker.get_instrument_name() == "Colorimtre HCFR"
            and getcfg("measurement_mode") != "R"
        ):
            # Automatically set raw measurement mode if not already
            # raw measurement mode
            measurement_mode = "R"
        elif self.worker.get_instrument_name() in ("Spyder4", "Spyder5") and getcfg(
            "measurement_mode"
        ) not in ("l", "c"):
            # Automatically set LCD measurement mode if not already
            # LCD or refresh measurement mode
            measurement_mode = "l"
        elif (
            self.worker.get_instrument_name() == "SpyderX"
            and getcfg("measurement_mode") != "l"
        ):
            # Automatically set LCD/generic measurement mode if not already
            # LCD/generic measurement mode
            measurement_mode = "l"
        if not getcfg("measurement_mode.backup", False):
            setcfg("measurement_mode.backup", getcfg("measurement_mode"))
        if measurement_mode:
            setcfg("measurement_mode", measurement_mode)
            self.update_measurement_mode()

    def set_pending_function(
        self,
        pending_function: Callable,
        *pending_function_args: tuple,
        **pending_function_kwargs: dict,
    ) -> None:
        """Set the pending function to be called later.

        Args:
            pending_function (callable): The function to call later.
            pending_function_args (tuple): Positional arguments for the
                function.
            pending_function_kwargs (dict): Keyword arguments for the function.
        """
        self.pending_function = pending_function
        self.pending_function_args = pending_function_args
        self.pending_function_kwargs = pending_function_kwargs

    def call_pending_function(self) -> None:
        """Call the pending function with its arguments."""
        # Needed for proper display updates under GNOME
        writecfg()
        if (
            sys.platform in ("darwin", "win32")
            or ISEXE
            or self.worker._use_patternwindow
        ):
            if self.worker._use_patternwindow:
                # Preliminary Wayland support. This still needs a lot
                # of work as Argyll doesn't support Wayland natively yet,
                # so we use virtual display to drive our own patch window.
                self.measureframe.show_controls(False)
            else:
                self.measureframe.Hide()
        debug_print(
            "[D] Calling pending function with args:", self.pending_function_args
        )
        wx.CallLater(
            100,
            self.pending_function,
            *self.pending_function_args,
            **self.pending_function_kwargs,
        )
        self.pending_function = None

    def calibrate_and_profile_btn_handler(self, event: wx.Event) -> None:
        """Setup calibration and characterization measurements.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if sys.platform == "darwin" or DEBUG:
            self.focus_handler(event)
        if self.check_show_macos_bugs_warning() is False:
            return
        if (
            check_set_argyll_bin()
            and self.check_overwrite(".cal")
            and self.check_overwrite(".ti3")
            and self.check_overwrite(PROFILE_EXT)
        ):
            self.setup_measurement(self.calibrate_and_profile)

    def calibrate_and_profile(self) -> None:
        """Start calibration measurements."""
        if self.measure_auto(self.calibrate_and_profile):
            return
        print("-" * 80)
        print(lang.getstr("button.calibrate_and_profile").replace("&&", "&"))
        setcfg("calibration.continue_next", 1)
        self.worker.dispcal_create_fast_matrix_shaper = False
        self.worker.dispread_after_dispcal = True
        if getcfg("calibration.interactive_display_adjustment") and not getcfg(
            "calibration.update"
        ):
            # Interactive adjustment, do not show progress dialog
            self.worker.interactive = True
        else:
            # No interactive adjustment, show progress dialog
            self.worker.interactive = False
        self.worker.start_calibration(
            self.calibrate_finish,
            progress_msg=lang.getstr("calibration"),
            continue_next=True,
            resume=bool(getattr(self, "measure_auto_after", None)),
        )

    def calibrate_finish(self, result: bool | Exception) -> None:
        """Start characterization measurements.

        Returns:
            result (bool | Exception): True if the calibration was successful,
                an Exception if there was an error, or False if the operation
                was cancelled.
        """
        self.worker.interactive = False
        if not isinstance(result, Exception) and result:
            wx.CallAfter(self.update_calibration_file_ctrl)
            cal = True if getcfg("trc") else get_data_path("linear.cal")
            self.worker.start_measurement(
                self.calibrate_and_profile_finish,
                apply_calibration=cal,
                progress_msg=lang.getstr("measuring.characterization"),
                resume=True,
                continue_next=True,
            )
        else:
            if isinstance(result, Exception):
                wx.CallAfter(show_result_dialog, result, self)
            self.Show()

    def calibrate_and_profile_finish(self, result: bool | Exception) -> None:
        """Build profile from characterization measurements.

        Returns:
            result (bool | Exception): True if the profile was created
                successfully, an Exception if there was an error, or False if
                the operation was cancelled.
        """
        start_timers = True
        if not isinstance(result, Exception) and result:
            result = self.check_copy_ti3()
        if not isinstance(result, Exception) and result:
            start_timers = False
            wx.CallAfter(
                self.start_profile_worker,
                lang.getstr("calibration_profiling.complete"),
                resume=True,
            )
        elif isinstance(result, Exception):
            wx.CallAfter(show_result_dialog, result, self)
        elif not getcfg("dry_run"):
            wx.CallAfter(
                InfoDialog,
                self,
                msg=lang.getstr("profiling.incomplete"),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
        self.Show(start_timers=start_timers)

    def check_copy_ti3(self) -> bool | Exception:
        """Check if the measurement file is valid and copy it if necessary.

        Returns:
            bool | Exception: The result, True if the measurement file is valid
            and copied successfully, an Exception if there was an error, or
            False if the operation was cancelled.
        """
        result = self.measurement_file_check_confirm(
            parent=getattr(self.worker, "progress_wnd", self)
        )
        if isinstance(result, tuple):
            result = self.worker.wrapup(copy=True, remove=False, ext_filter=[".ti3"])
        if isinstance(result, Exception) or not result:
            self.worker.stop_progress()
        return result

    def start_profile_worker(self, success_msg: str, resume: bool = False) -> None:
        """Start the profile worker to create a profile from measurements.

        Args:
            success_msg (str): The message to show on successful profile creation.
            resume (bool): If True, resume the profile creation process.
        """
        name = getcfg("profile.name.expanded")
        path = os.path.join(getcfg("profile.save_path"), name, name + PROFILE_EXT)
        self.lut3d_set_path(path, set_mr_sim_profile=False)
        continue_next = getcfg("3dlut.create") and not os.path.isfile(self.lut3d_path)
        self.worker.interactive = False
        self.worker.start(
            self.profile_finish,
            self.worker.create_profile,
            ckwargs={
                "success_msg": success_msg,
                "failure_msg": lang.getstr("profiling.incomplete"),
                "install_3dlut": getcfg("3dlut.create"),
            },
            wkwargs={"tags": True},
            progress_msg=lang.getstr("create_profile"),
            resume=resume,
            continue_next=continue_next,
        )

    def gamap_btn_handler(self, event: wx.Event) -> None:
        """Toggle the gamap frame.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if not hasattr(self, "gamapframe"):
            self.init_gamapframe()
        if self.gamapframe.IsShownOnScreen():
            self.gamapframe.Raise()
        else:
            self.gamapframe.Center()
            self.gamapframe.SetPosition(
                (
                    -1,
                    self.GetPosition()[1]
                    + self.GetSize()[1]
                    - self.gamapframe.GetSize()[1]
                    - 100,
                )
            )
            self.gamapframe.Show(not self.gamapframe.IsShownOnScreen())

    def current_cal_choice(self, silent: bool = False) -> None | bool | int | str:
        """Prompt user to keep, clear, or embed current calibration.

        Args:
            silent (bool): If True, do not show any dialogs, just return the
                result.

        Returns:
            None: If the current calibration should be embedded.
            bool: False, if no calibration should be embedded.
            str: The filename, if a .cal file should be used.
            int: wx.ID_CANCEL if whole operation should be cancelled.
        """
        if config.is_uncalibratable_display():
            return False
        cal = getcfg("calibration.file", False)
        options_dispcal = None
        if cal:
            filename, ext = os.path.splitext(cal)
            if ext.lower() in (".icc", ".icm"):
                self.worker.options_dispcal = []
                try:
                    profile = ICCProfile(cal)
                except (OSError, ICCProfileInvalidError):
                    InfoDialog(
                        self,
                        msg=lang.getstr("profile.invalid") + "\n" + cal,
                        ok=lang.getstr("ok"),
                        bitmap=get_icon(32, "dialog-error"),
                    )
                    self.start_timers()
                    return wx.ID_CANCEL
                else:
                    # get dispcal options if present
                    options_dispcal = [
                        f"-{arg}" for arg in get_options_from_profile(profile)[0]
                    ]
            cal = f"{filename}.cal" if os.path.isfile(filename + ".cal") else None
        if self.worker.argyll_version < [1, 1, 0] or not self.worker.has_lut_access():
            # If Argyll < 1.1, we cannot save the current VideoLUT to use it.
            # For web, there is no point in using the current VideoLUT as it
            # may not be from the display we render on (and we cannot save it
            # to begin with as there is no VideoLUT access).
            # So an existing .cal file or no calibration are the only options.
            can_use_current_cal = False
        else:
            can_use_current_cal = True
        if cal:
            msgstr = "dialog.cal_info"
            icon = "information"
        elif can_use_current_cal:
            msgstr = "dialog.current_cal_warning"
            icon = "warning"
        else:
            msgstr = "dialog.linear_cal_info"
            icon = "information"
        dlg = ConfirmDialog(
            self,
            msg=lang.getstr(msgstr, os.path.basename(cal) if cal else None),
            ok=lang.getstr("continue"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, f"dialog-{icon}"),
        )
        border = 12
        if can_use_current_cal or cal:
            dlg.reset_cal_ctrl = wx.CheckBox(
                dlg, -1, lang.getstr("calibration.use_linear_instead")
            )
            dlg.sizer3.Add(
                dlg.reset_cal_ctrl, flag=wx.TOP | wx.ALIGN_LEFT, border=border
            )
            border = 4
        dlg.embed_cal_ctrl = wx.CheckBox(dlg, -1, lang.getstr("calibration.embed"))

        def embed_cal_ctrl_handler(event: wx.Event) -> None:
            """Handle the embed calibration checkbox event."""
            embed_cal = dlg.embed_cal_ctrl.GetValue()
            dlg.reset_cal_ctrl.Enable(embed_cal)
            if not embed_cal:
                dlg.reset_cal_ctrl.SetValue(True)

        if can_use_current_cal or cal:
            dlg.embed_cal_ctrl.Bind(wx.EVT_CHECKBOX, embed_cal_ctrl_handler)
        dlg.embed_cal_ctrl.SetValue(bool(can_use_current_cal or cal))
        dlg.sizer3.Add(dlg.embed_cal_ctrl, flag=wx.TOP | wx.ALIGN_LEFT, border=border)
        dlg.sizer0.SetSizeHints(dlg)
        dlg.sizer0.Layout()
        result = wx.ID_OK if silent else dlg.ShowModal()
        if can_use_current_cal or cal:
            reset_cal = dlg.reset_cal_ctrl.GetValue()
        embed_cal = dlg.embed_cal_ctrl.GetValue()
        dlg.Destroy()
        if result == wx.ID_CANCEL:
            self.start_timers()
            return wx.ID_CANCEL
        if not embed_cal:
            if can_use_current_cal and reset_cal:
                self.reset_cal()
            return False
        if not (can_use_current_cal or cal) or reset_cal:
            return get_data_path("linear.cal")
        if cal:
            if options_dispcal:
                self.worker.options_dispcal = options_dispcal
            return cal
        return None

    def restore_measurement_mode(self) -> None:
        """Restore the measurement mode from backup."""
        if getcfg("measurement_mode.backup", False):
            setcfg("measurement_mode", getcfg("measurement_mode.backup"))
            setcfg("measurement_mode.backup", None)
            if getcfg("comport.number.backup", False):
                setcfg("comport.number", getcfg("comport.number.backup"))
                setcfg("comport.number.backup", None)
                self.update_comports()
            else:
                self.update_measurement_mode()
        if getcfg("observer.backup", False):
            setcfg("observer", getcfg("observer.backup"))
            setcfg("observer.backup", None)

    def restore_testchart(self) -> None:
        """Restore the testchart file from backup."""
        if getcfg("testchart.file.backup", False):
            self.set_testchart(getcfg("testchart.file.backup"))
            setcfg("testchart.file.backup", None)

    def measure_auto(
        self, measure_auto_after: Callable, *measure_auto_after_args: tuple
    ) -> None | bool:
        """Automatically create a CCMX with EDID reference.

        Args:
            measure_auto_after (callable): Function to call after measurement.
            measure_auto_after_args (tuple): Arguments to pass to the function.

        Returns:
            None | bool: True if the automatic measurement was started, False
                otherwise, None if the measurement mode is not 'auto' or
                measure_auto_after is already set.
        """
        if getcfg("measurement_mode") != "auto" or getattr(
            self, "measure_auto_after", None
        ):
            return None
        if not self.worker.get_display_edid():
            self.measure_auto_finish(Error("EDID not available"))
            return True
        self.measure_auto_after = measure_auto_after
        self.measure_auto_after_args = measure_auto_after_args
        if not is_ccxx_testchart():
            ccxx_testchart = get_ccxx_testchart()
            if not ccxx_testchart:
                self.measure_auto_finish(
                    Error(lang.getstr("not_found", lang.getstr("ccxx.ti1")))
                )
                return True
            setcfg("testchart.file.backup", getcfg("testchart.file"))
            self.set_testchart(ccxx_testchart)
        self.setup_ccxx_measurement()
        self.just_measure(get_data_path("linear.cal"), self.measure_auto_finish)
        return True

    def measure_auto_finish(self, result: bool | Exception) -> None:
        """Finish the automatic CCMX creation.

        Args:
            result (bool | Exception): The result of the measurement.
        """
        ti3_path = os.path.join(
            self.worker.tempdir or "", getcfg("profile.name.expanded") + ".ti3"
        )
        self.restore_testchart()
        if isinstance(result, Exception) or not result:
            self.measure_auto_after = None
            if isinstance(result, Exception):
                wx.CallAfter(show_result_dialog, result, self)
            self.Show()
            self.worker.stop_progress()
            return

        edid = self.worker.get_display_edid()
        default_file = (
            edid.get("monitor_name", edid.get("ascii", str(edid["product_id"])))
            + PROFILE_EXT
        )
        profile_path = os.path.join(self.worker.tempdir, default_file)
        profile = ICCProfile.from_edid(edid)
        try:
            profile.write(profile_path)
        except Exception as exception:
            self.measure_auto_finish(exception)
            return
        luminance = None
        if self.worker.get_instrument_name() == "ColorHug":
            # Get the factory calibration so we can do luminance scaling
            # NOTE that this currently only works for the ColorHug,
            # NOT the ColorHug2! (but it's probably not needed for the
            # ColorHug2 anyway)
            for line in self.worker.output:
                if line.lower().startswith("serial number:"):
                    serial = line.split(":", 1)[-1].strip()
                    calibration = f"calibration-{serial}.ti3"
                    path = os.path.join(config.get_argyll_data_dir(), calibration)
                    if not os.path.isfile(path):
                        print("Retrieving factory calibration for ColorHug", serial)
                        url = (
                            "https://raw.githubusercontent.com/hughski"
                            f"/colorhug-calibration/master/data/{calibration}"
                        )
                        try:
                            response = urllib.request.urlopen(url)  # noqa: S310
                        except Exception as exception:
                            self.measure_auto_finish(exception)
                            return
                        body = response.read()
                        response.close()
                        if body.decode().strip().startswith("CTI3"):
                            print("Successfully retrieved", url)
                            try:
                                with open(path, "wb") as calibrationfile:
                                    calibrationfile.write(body)
                            except Exception as exception:
                                print(exception)
                        else:
                            print(f"Got unexpected answer from {url}:")
                            print(body)
                    if os.path.isfile(path):
                        print("Using factory calibration", path)
                        try:
                            cgats = CGATS(path)
                        except (OSError, CGATSError) as exception:
                            print(exception)
                        else:
                            white = cgats.queryi1({"RGB_R": 1, "RGB_G": 1, "RGB_B": 1})
                            if white:
                                luminance = white["XYZ_Y"]
                                print(
                                    f"Using luminance {luminance:.2f} "
                                    "from factory calibration"
                                )
        if self.create_colorimeter_correction_handler(
            None, [profile_path, ti3_path], luminance=luminance
        ):
            self.measure_auto_after(*self.measure_auto_after_args)
        else:
            self.Show()
            self.worker.stop_progress()
        self.measure_auto_after = None

    def measure_handler(self, event: None | wx.Event = None) -> None:
        """Setup characterization measurements.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        self.setup_ccxx_measurement()
        if check_set_argyll_bin() and self.check_overwrite(".ti3"):
            if is_ccxx_testchart():
                # Use linear calibration for measuring CCXX testchart
                apply_calibration = get_data_path("linear.cal")
            else:
                apply_calibration = self.current_cal_choice()
            if apply_calibration != wx.ID_CANCEL:
                self.setup_measurement(self.just_measure, apply_calibration)
        else:
            self.restore_measurement_mode()
            self.restore_testchart()

    def specplot_handler(self, event: wx.Event) -> None:
        """Run Argyll specplot for a selected file."""
        if not check_set_argyll_bin():
            return
        path = None
        default_dir, default_file = get_verified_path("last_specplot_path")
        dlg = wx.FileDialog(
            self,
            lang.getstr("specplot.choose"),
            defaultDir=default_dir,
            defaultFile=default_file,
            wildcard=lang.getstr("filetype.any") + "|*.*",
            style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
        )
        dlg.Center(wx.BOTH)
        result = dlg.ShowModal()
        if result == wx.ID_OK:
            path = dlg.GetPath()
        dlg.Destroy()
        if not path:
            return
        setcfg("last_specplot_path", path)
        cmd = get_argyll_util("specplot")
        if not cmd:
            show_result_dialog(
                Error(lang.getstr("argyll.util.not_found", "specplot")), self
            )
            return
        args = ["-v"]
        if getcfg("extra_args.specplot").strip():
            args += parse_argument_string(getcfg("extra_args.specplot"))
        args.append(path)
        self.worker.interactive = False
        self.worker.start(
            self.specplot_consumer,
            self.worker.exec_cmd,
            wargs=(cmd, args),
            wkwargs={"skip_scripts": True},
            progress_msg=lang.getstr("specplot.run"),
            fancy=False,
        )

    def specplot_consumer(self, result: bool | Exception) -> None:
        """Handle completion of a specplot run."""
        if isinstance(result, Exception):
            show_result_dialog(result, self)
        self.worker.wrapup(False)
        self.Show()

    def profile_btn_handler(self, event: wx.Event) -> None:
        """Setup characterization measurements.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if sys.platform == "darwin" or DEBUG:
            self.focus_handler(event)
        if self.check_show_macos_bugs_warning(cal=False) is False:
            return
        if (
            check_set_argyll_bin()
            and self.check_overwrite(".ti3")
            and self.check_overwrite(PROFILE_EXT)
        ):
            apply_calibration = self.current_cal_choice(
                silent=isinstance(event, CustomEvent)
            )
            if apply_calibration != wx.ID_CANCEL:
                self.setup_measurement(self.just_profile, apply_calibration)

    def setup_ccxx_measurement(self) -> None:
        """Setup measurement for CCXX testchart."""
        if not is_ccxx_testchart():
            return
        # Allow different location to store measurements
        path = getcfg("profile.save_path")
        if not path:
            self.profile_save_path_btn_handler(None)
            path = getcfg("profile.save_path")
        if not path:
            return
        if not waccess(path, os.W_OK):
            show_result_dialog(
                Error(lang.getstr("error.access_denied.write", path)), self
            )
            return
        setcfg("measurement.save_path", path)
        if getcfg("observer") == "1931_2":
            basename = "{} & {} {}".format(
                self.worker.get_instrument_name(),
                self.worker.get_display_name(True, True),
                strftime("%Y-%m-%d %H-%M-%S"),
            )
        else:
            basename = "{} ({} {}) & {} {}".format(
                self.worker.get_instrument_name(),
                lang.getstr(f"observer.{getcfg('observer')}"),
                lang.getstr("observer"),
                self.worker.get_display_name(True, True),
                strftime("%Y-%m-%d %H-%M-%S"),
            )
        setcfg("measurement.name.expanded", make_filename_safe(basename))

    def just_measure(
        self,
        apply_calibration: None | bool | str,
        consumer: None | Callable = None,
    ) -> None:
        """Start characterization measurements.

        Args:
            apply_calibration (str | bool): Path to the calibration file to
                apply, or True for linear calibration.
            consumer (callable, optional): Function to call when the
                measurements are finished. If None, the default
                `just_measure_finish` function will be used.
        """
        if self.measure_auto(self.just_measure, apply_calibration):
            return
        print("-" * 80)
        print(lang.getstr("measure"))
        self.worker.dispread_after_dispcal = False
        self.worker.interactive = config.get_display_name() == "Untethered"
        setcfg("calibration.file.previous", None)
        continue_next = bool(consumer)
        resume = bool(getattr(self, "measure_auto_after", None))
        if not consumer:
            consumer = self.just_measure_finish
        self.worker.start_measurement(
            consumer,
            apply_calibration,
            progress_msg=lang.getstr("measuring.characterization"),
            continue_next=continue_next,
            resume=resume,
        )

    def just_measure_finish(self, result: bool | Exception) -> None:
        """Finish characterization measurements.

        Args:
            result (bool | Exception): Result of the measurements.
        """
        if not isinstance(result, Exception) and result:
            result = self.check_copy_ti3()
        self.worker.wrapup(copy=False, remove=True)
        if isinstance(result, Exception) or not result:
            if isinstance(result, Exception):
                wx.CallAfter(show_result_dialog, result, self)
        elif is_ccxx_testchart():
            try:
                cgats = CGATS(
                    os.path.join(
                        getcfg("measurement.save_path"),
                        getcfg("measurement.name.expanded"),
                        getcfg("measurement.name.expanded"),
                    )
                    + ".ti3"
                )
            except Exception as exception:
                wx.CallAfter(show_result_dialog, exception, self)
            else:
                if cgats.queryv1("INSTRUMENT_TYPE_SPECTRAL") == b"YES":
                    setcfg("last_reference_ti3_path", cgats.filename)
                else:
                    setcfg("last_colorimeter_ti3_path", cgats.filename)
                if getcfg("comport.number.backup", False):
                    # Measurements were started from colorimeter correction
                    # creation dialog
                    paths = []
                    if (
                        getcfg("last_reference_ti3_path", False)
                        and os.path.isfile(getcfg("last_reference_ti3_path"))
                        and (
                            getcfg("colorimeter_correction.type") == "spectral"
                            or (
                                getcfg("last_colorimeter_ti3_path", False)
                                and os.path.isfile(getcfg("last_colorimeter_ti3_path"))
                                and self.worker.get_instrument_name()
                                == getcfg("colorimeter_correction.instrument")
                            )
                        )
                    ):
                        paths.append(getcfg("last_reference_ti3_path"))
                        if self.worker.get_instrument_name() == getcfg(
                            "colorimeter_correction.instrument"
                        ):
                            paths.append(getcfg("last_colorimeter_ti3_path"))
                    wx.CallAfter(
                        self.create_colorimeter_correction_handler, True, paths=paths
                    )
        else:
            wx.CallAfter(
                self.just_measure_show_result,
                os.path.join(
                    getcfg("profile.save_path"),
                    getcfg("profile.name.expanded"),
                    getcfg("profile.name.expanded") + ".ti3",
                ),
            )
        self.Show(start_timers=True)
        self.restore_measurement_mode()
        self.restore_testchart()

    def just_measure_show_result(self, path: str) -> None:
        """Show a dialog to confirm the completion of the measurements.

        Args:
            path (str): Path to the measurement file.
        """
        dlg = ConfirmDialog(
            self,
            msg=lang.getstr("measurements.complete"),
            ok=lang.getstr("ok"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, "dialog-question"),
        )
        if dlg.ShowModal() == wx.ID_OK:
            launch_file(os.path.dirname(path))
        dlg.Destroy()

    def just_profile(self, apply_calibration: bool | str) -> None:
        """Start characterization measurements.

        Args:
            apply_calibration (bool | str): Path to the calibration file to
                apply, or True for linear calibration.
        """
        if self.measure_auto(self.just_profile, apply_calibration):
            return
        print("-" * 80)
        print(lang.getstr("button.profile"))
        self.worker.dispread_after_dispcal = False
        self.worker.interactive = config.get_display_name() == "Untethered"
        setcfg("calibration.file.previous", None)
        self.worker.start_measurement(
            self.just_profile_finish,
            apply_calibration,
            progress_msg=lang.getstr("measuring.characterization"),
            continue_next=config.get_display_name() != "Untethered",
            resume=bool(getattr(self, "measure_auto_after", None)),
        )

    def just_profile_finish(self, result: bool | Exception) -> None:
        """Build profile from characterization measurements.

        Args:
            result (bool | Exception): Result of the profile creation.
        """
        start_timers = True
        if not isinstance(result, Exception) and result:
            result = self.check_copy_ti3()
        if not isinstance(result, Exception) and result:
            start_timers = False
            wx.CallAfter(
                self.start_profile_worker,
                lang.getstr("profiling.complete"),
                resume=True,
            )
        elif isinstance(result, Exception):
            wx.CallAfter(show_result_dialog, result, self)
        elif not getcfg("dry_run"):
            wx.CallAfter(
                InfoDialog,
                self,
                msg=lang.getstr("profiling.incomplete"),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
        self.Show(start_timers=start_timers)

    def profile_finish(
        self,
        result: bool | Exception,
        profile_path: None | str = None,
        success_msg: str = "",
        failure_msg: str = "",
        preview: bool = True,
        skip_scripts: bool = False,
        allow_show_log: bool = True,
        install_3dlut: bool = False,
    ) -> None:
        """Finish profile creation.

        Show result dialog, update controls and save profile path.

        Args:
            result (bool | Exception): Result of the profile creation.
            profile_path (None | str, optional): Path to the created profile.
            success_msg (str): Message to show on success.
            failure_msg (str): Message to show on failure.
            preview (bool): Whether to show a preview of the profile.
            skip_scripts (bool): Whether to skip running scripts.
            allow_show_log (bool): Whether to allow showing the log.
            install_3dlut (bool): Whether to install the 3D LUT.
        """
        if isinstance(result, Exception) or not result:
            if isinstance(result, Exception):
                show_result_dialog(result, self)
            else:
                InfoDialog(
                    self,
                    msg=failure_msg,
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )

            if sys.platform == "darwin":
                # For some reason, the call to enable_menus() in Show()
                # sometimes isn't enough under MacOS (e.g. after calibrate &
                # profile)
                self.enable_menus()
            self.start_timers(True)
            if not getcfg("dry_run"):
                setcfg("calibration.file.previous", None)
            return
        if getcfg("log.autoshow") and allow_show_log:
            self.infoframe_toggle_handler(show=True)
        self.install_3dlut = install_3dlut
        if profile_path:
            profile_save_path = os.path.splitext(profile_path)[0]
        else:
            profile_save_path = os.path.join(
                getcfg("profile.save_path"),
                getcfg("profile.name.expanded"),
                getcfg("profile.name.expanded"),
            )
            profile_path = profile_save_path + PROFILE_EXT
        self.cal = profile_path
        profile = None
        filename, ext = os.path.splitext(profile_path)
        extra = []
        cinfo = []
        vinfo = []
        has_cal = False
        try:
            profile = ICCProfile(profile_path)
        except (OSError, ICCProfileInvalidError):
            InfoDialog(
                self,
                msg=lang.getstr("profile.invalid") + "\n" + profile_path,
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            self.start_timers(True)
            setcfg("calibration.file.previous", None)
            return

        has_cal = isinstance(profile.tags.get("vcgt"), VideoCardGammaType)
        if profile.profileClass != b"mntr" or profile.colorSpace != b"RGB":
            InfoDialog(
                self,
                msg=lang.getstr("profiling.complete"),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-information"),
            )
            self.start_timers(True)
            setcfg("calibration.file.previous", None)
            return
        if getcfg("calibration.file", False) != profile_path:
            # Load profile
            (options_dispcal, options_colprof) = get_options_from_profile(profile)
            if options_dispcal or options_colprof:
                cal = profile_save_path + ".cal"
                sel = self.calibration_file_ctrl.GetSelection()
                if options_dispcal and self.recent_cals[sel] == cal:
                    self.recent_cals.remove(cal)
                    self.calibration_file_ctrl.Delete(sel)
                if options_dispcal and options_colprof:
                    setcfg("settings.changed", 0)
                    self.load_cal_handler(
                        None,
                        path=profile_path,
                        update_profile_name=False,
                        silent=True,
                        load_vcgt=False,
                    )
                else:
                    setcfg("calibration.file", profile_path)
                    setcfg("3dlut.output.profile", profile_path)
                    setcfg("measurement_report.output_profile", profile_path)
                    self.update_controls(update_profile_name=False)
        # Get 3D LUT options
        self.lut3d_set_path()
        # Check if we want to automatically create 3D LUT
        if (
            install_3dlut
            and getcfg("3dlut.create")
            and not os.path.isfile(self.lut3d_path)
        ):
            # Update curve viewer if shown
            self.lut_viewer_load_lut(profile=profile)
            # Create 3D LUT
            self.lut3d_create_handler(None)
            return
        if hasattr(self.worker, "_disabler"):
            # This shouldn't happen
            self.worker.stop_progress()
        if "meta" in profile.tags:
            for key in ("avg", "max", "rms"):
                try:
                    dE = float(profile.tags.meta.getvalue(f"ACCURACY_dE76_{key}"))  # noqa: N806
                except (TypeError, ValueError):
                    pass
                else:
                    lstr = f"{lang.getstr('profile.self_check')}:"
                    if lstr not in extra:
                        extra.append(lstr)
                    extra.append(
                        " {} {:.2f}".format(
                            lang.getstr(f"profile.self_check.{key}"), dE
                        )
                    )
            gamuts = (
                ("srgb", "sRGB", GAMUT_VOLUME_SRGB),
                ("adobe-rgb", "Adobe RGB", GAMUT_VOLUME_ADOBERGB),
                ("dci-p3", "DCI P3", GAMUT_VOLUME_SMPTE431_P3),
            )
            for key, name, _volume in gamuts:
                try:
                    gamut_coverage = profile.tags.meta.getvalue(
                        f"GAMUT_coverage({key})"
                    )
                    gamut_coverage = (
                        float(gamut_coverage)
                        if gamut_coverage is not None
                        else gamut_coverage
                    )
                except (TypeError, ValueError):
                    traceback.print_exc()
                    gamut_coverage = None
                if gamut_coverage:
                    cinfo.append(f"{gamut_coverage:.1%} {name}")
            try:
                gamut_volume = float(profile.tags.meta.getvalue("GAMUT_volume"))
            except (TypeError, ValueError):
                traceback.print_exc()
                gamut_volume = None
            if gamut_volume:
                for _key, name, volume in gamuts:
                    vinfo.append(
                        f"{gamut_volume * GAMUT_VOLUME_SRGB / volume:.1%} {name}"
                    )
                    if len(vinfo) == len(cinfo):
                        break
        if config.is_virtual_display() or install_3dlut:
            installable = False
            title = APPNAME
            if self.lut3d_path and os.path.isfile(self.lut3d_path):
                # 3D LUT file already exists
                if getcfg("3dlut.format") in (
                    "madVR",
                    "ReShade",
                ) or config.check_3dlut_format("Prisma"):
                    ok = lang.getstr("3dlut.install")
                else:
                    ok = lang.getstr("3dlut.save_as")
            else:
                ok = lang.getstr("3dlut.create")
            cancel = lang.getstr("cancel")
        else:
            if not self.check_profile_b2a_hires(profile):
                return
            installable = True
            title = lang.getstr("profile.install")
            ok = lang.getstr("profile.install")
            cancel = lang.getstr("profile.do_not_install")
        if not success_msg:
            if installable:
                success_msg = lang.getstr(
                    "dialog.install_profile",
                    (
                        os.path.basename(profile_path),
                        self.display_ctrl.GetStringSelection(),
                    ),
                )
            else:
                success_msg = lang.getstr("profiling.complete")
        if extra:
            extra = ",".join(extra).replace(":,", ":").replace(",,", "\n")
            success_msg = f"{success_msg}\n\n{extra}".strip()
        # Always load calibration curves
        self.load_cal(cal=profile_path, silent=True)
        # Check profile metadata
        share_profile = None
        if not self.profile_share_get_meta_error(profile):
            share_profile = lang.getstr("profile.share")
        dlg = ConfirmDialog(
            self,
            msg=success_msg,
            title=title,
            ok=ok,
            cancel=cancel,
            bitmap=get_icon(32, APPNAME + "-profile-info"),
            alt=share_profile,
        )
        if cinfo or vinfo:
            gamut_info_sizer = wx.FlexGridSizer(2, 2, 0, 24)
            dlg.sizer3.Add(gamut_info_sizer, flag=wx.TOP, border=14)
            if cinfo:
                label = wx.StaticText(dlg, -1, lang.getstr("gamut.coverage"))
                font = label.GetFont()
                font.SetWeight(wx.FONTWEIGHT_BOLD)
                label.SetFont(font)
                gamut_info_sizer.Add(label)
            if vinfo:
                label = wx.StaticText(dlg, -1, lang.getstr("gamut.volume"))
                font = label.GetFont()
                font.SetWeight(wx.FONTWEIGHT_BOLD)
                label.SetFont(font)
            else:
                label = (1, 1)
            gamut_info_sizer.Add(label)
            if cinfo:
                gamut_info_sizer.Add(wx.StaticText(dlg, -1, "\n".join(cinfo)))
            if vinfo:
                gamut_info_sizer.Add(wx.StaticText(dlg, -1, "\n".join(vinfo)))
        self.modaldlg = dlg
        if share_profile:
            # Show share profile button
            dlg.Unbind(wx.EVT_BUTTON, dlg.alt)
            dlg.Bind(wx.EVT_BUTTON, self.profile_share_handler, id=dlg.alt.GetId())
        if preview and has_cal and self.worker.calibration_loading_supported:
            # Show calibration preview checkbox
            self.preview = wx.CheckBox(dlg, -1, lang.getstr("calibration.preview"))
            self.preview.SetValue(True)
            dlg.Bind(wx.EVT_CHECKBOX, self.preview_handler, id=self.preview.GetId())
            dlg.sizer3.Add(self.preview, flag=wx.TOP | wx.ALIGN_LEFT, border=14)
            if LUTFrame and not ProfileInfoFrame:
                # Disabled, use profile information window instead
                self.show_lut = wx.CheckBox(
                    dlg, -1, lang.getstr("calibration.show_lut")
                )
                dlg.Bind(
                    wx.EVT_CHECKBOX, self.show_lut_handler, id=self.show_lut.GetId()
                )
                dlg.sizer3.Add(self.show_lut, flag=wx.TOP | wx.ALIGN_LEFT, border=4)
                self.show_lut.SetValue(bool(getcfg("lut_viewer.show")))
                if not getattr(self, "lut_viewer", None):
                    self.init_lut_viewer(
                        profile=profile, show=getcfg("lut_viewer.show")
                    )
        else:
            dlg.sizer3.Add((0, 10))
        self.show_profile_info = wx.CheckBox(dlg, -1, lang.getstr("profile.info.show"))
        dlg.Bind(
            wx.EVT_CHECKBOX,
            self.profile_info_handler,
            id=self.show_profile_info.GetId(),
        )
        dlg.sizer3.Add(self.show_profile_info, flag=wx.TOP | wx.ALIGN_LEFT, border=4)
        id_ = profile.calculate_id(False) if profile.ID == "\0" * 16 else profile.ID
        if id_ in self.profile_info:
            self.show_profile_info.SetValue(self.profile_info[id_].IsShownOnScreen())
        if installable:
            if sys.platform == "win32":
                # Get profile loader config
                cur = self.send_command(
                    "apply-profiles", "getcfg profile.load_on_login"
                )
                if cur:
                    try:
                        cur = int(cur.split()[-1])
                    except Exception:
                        pass
                    else:
                        setcfg("profile.load_on_login", cur)
                else:
                    # Profile loader not running? Fall back to config files

                    # 1. Remember current config
                    items = config.CFG.items(config.configparser.DEFAULTSECT)

                    # 2. Read in profile loader config. Result is unison of
                    #    current config and profile loader config.
                    initcfg("apply-profiles", force_load=True)

                    # 3. Restore current config (but do not override profile
                    #    loader options)
                    for name, value in items:
                        if name != "profile.load_on_login" and not name.startswith(
                            "profile_loader"
                        ):
                            config.CFG.set(config.configparser.DEFAULTSECT, name, value)

                    # 4. Remove profile loader options from current config
                    for name in DEFAULTS:
                        if name.startswith("profile_loader"):
                            setcfg(name, None)
            if sys.platform != "darwin" or TEST:
                os_cal = (
                    sys.platform == "win32"
                    and sys.getwindowsversion() >= (6, 1)
                    and util_win.calibration_management_isenabled()
                )
                label = get_profile_load_on_login_label(os_cal)
                self.profile_load_on_login = wx.CheckBox(dlg, -1, label)
                self.profile_load_on_login.SetValue(
                    bool(getcfg("profile.load_on_login") or os_cal)
                )
                dlg.Bind(
                    wx.EVT_CHECKBOX,
                    self.profile_load_on_login_handler,
                    id=self.profile_load_on_login.GetId(),
                )
                dlg.sizer3.Add(
                    self.profile_load_on_login,
                    flag=wx.TOP | wx.ALIGN_LEFT,
                    border=14,
                )
                dlg.sizer3.Add((1, 4))
                if sys.platform == "win32" and sys.getwindowsversion() >= (6, 1):
                    self.profile_load_by_os = wx.CheckBox(
                        dlg, -1, lang.getstr("profile.load_on_login.handled_by_os")
                    )
                    self.profile_load_by_os.SetValue(bool(os_cal))
                    dlg.Bind(
                        wx.EVT_CHECKBOX,
                        self.profile_load_by_os_handler,
                        id=self.profile_load_by_os.GetId(),
                    )
                    dlg.sizer3.Add(
                        self.profile_load_by_os,
                        flag=wx.LEFT | wx.ALIGN_LEFT,
                        border=16,
                    )
                    dlg.sizer3.Add((1, 4))
                    self.profile_load_on_login_handler()
            if (
                (
                    (
                        sys.platform == "darwin"
                        or (
                            sys.platform != "win32"
                            and self.worker.argyll_version >= [1, 1, 0]
                        )
                    )
                    and (os.geteuid() == 0 or which("sudo"))
                )
                or (
                    sys.platform == "win32"
                    and sys.getwindowsversion() >= (6,)
                    and self.worker.argyll_version > [1, 1, 1]
                )
                or TEST
            ):
                # Linux, OSX or Vista and later
                # NOTE: System install scope is currently not implemented
                # correctly in dispwin 1.1.0, but a patch is trivial and
                # should be in the next version
                # 2010-06-18: Do not offer system install in DisplayCAL when
                # installing via GCM or oyranos FIXME: oyranos-monitor can't
                # be run via sudo
                self.install_profile_user = wx.RadioButton(
                    dlg, -1, lang.getstr("profile.install_user"), style=wx.RB_GROUP
                )
                self.install_profile_user.SetValue(
                    getcfg("profile.install_scope") == "u"
                )
                dlg.Bind(
                    wx.EVT_RADIOBUTTON,
                    self.install_profile_scope_handler,
                    id=self.install_profile_user.GetId(),
                )
                dlg.sizer3.Add(
                    self.install_profile_user,
                    flag=wx.TOP | wx.ALIGN_LEFT,
                    border=10,
                )
                self.install_profile_systemwide = wx.RadioButton(
                    dlg, -1, lang.getstr("profile.install_local_system")
                )
                self.install_profile_systemwide.SetValue(
                    getcfg("profile.install_scope") == "l"
                )
                dlg.Bind(
                    wx.EVT_RADIOBUTTON,
                    self.install_profile_scope_handler,
                    id=self.install_profile_systemwide.GetId(),
                )
                dlg.sizer3.Add(
                    self.install_profile_systemwide,
                    flag=wx.TOP | wx.ALIGN_LEFT,
                    border=4,
                )
                if sys.platform == "darwin" and os.path.isdir(
                    "/Network/Library/ColorSync/Profiles"
                ):
                    self.install_profile_network = wx.RadioButton(
                        dlg, -1, lang.getstr("profile.install_network")
                    )
                    self.install_profile_network.SetValue(
                        getcfg("profile.install_scope") == "n"
                    )
                    dlg.Bind(
                        wx.EVT_RADIOBUTTON,
                        self.install_profile_scope_handler,
                        id=self.install_profile_network.GetId(),
                    )
                    dlg.sizer3.Add(
                        self.install_profile_network,
                        flag=wx.TOP | wx.ALIGN_LEFT,
                        border=4,
                    )
                self.install_profile_scope_handler(None)
            else:
                setcfg("profile.install_scope", "u")
        dlg.sizer0.SetSizeHints(dlg)
        dlg.sizer0.Layout()
        dlg.ok.SetDefault()
        dlg.profile = profile
        dlg.profile_path = profile_path
        dlg.skip_scripts = skip_scripts
        dlg.preview = preview
        dlg.ok.Unbind(wx.EVT_BUTTON)
        dlg.ok.Bind(wx.EVT_BUTTON, lambda event: self.profile_finish_action(event.Id))
        result = dlg.ShowWindowModalBlocking()
        if result == wx.ID_CANCEL:
            self.profile_finish_action(result)

    def profile_finish_action(self, result: int) -> None:
        """Handle the action after finishing profile installation.

        Args:
            result (int): The result of the dialog action, typically wx.ID_OK
                or wx.ID_CANCEL.
        """
        lut3d = config.is_virtual_display() or self.install_3dlut
        if (
            result == wx.ID_OK
            and getcfg("3dlut.format") == "madVR"
            and not hasattr(self.worker, "madtpg")
        ):
            # madVR has an API for installing 3D LUTs
            # Prisma has a HTTP REST interface for uploading and
            # configuring 3D LUTs
            try:
                self.worker.madtpg_init()
            except Exception as exception:
                print("Could not initialize madTPG:", exception)
        madtpg = getattr(self.worker, "madtpg", None)
        # Note: madVR HDR 3D LUT install API was added September 2017,
        # we don't require it so check availability
        install_3dlut_api = (
            getcfg("3dlut.format") == "madVR"
            and (
                not getcfg("3dlut.trc").startswith("smpte2084")
                or hasattr(madtpg, "load_hdr_3dlut_file")
            )
        ) or config.check_3dlut_format("Prisma")
        if result != wx.ID_OK or lut3d:
            if self.modaldlg.preview:
                if getcfg("calibration.file", False):
                    # Load LUT curves from last used .cal file
                    self.load_cal(silent=True)
                    if not getcfg("calibration.autoload"):
                        # Reload display profile into videoLUT
                        self.load_display_profile_cal(True, False)
                else:
                    # Load LUT curves from current display profile (if any,
                    # and if it contains curves)
                    self.load_display_profile_cal(True)
                if getattr(self, "preview", None):
                    self.preview.SetValue(False)
            if (
                result != wx.ID_OK
                or not self.lut3d_path
                or not os.path.isfile(self.lut3d_path)
                or not install_3dlut_api
            ):
                self.profile_finish_consumer()
        if result == wx.ID_OK:
            producer = None
            if lut3d:
                if self.lut3d_path and os.path.isfile(self.lut3d_path):
                    # 3D LUT file already exists
                    if install_3dlut_api:
                        filename = self.setup_patterngenerator(
                            self, lang.getstr("3dlut.install"), True
                        )
                        if not filename:
                            if filename is None:
                                # User cancelled
                                self.profile_finish_consumer()
                            return
                        producer = self.worker.install_3dlut
                        wargs = (self.lut3d_path, filename)
                        wkwargs = None
                        progress_msg = lang.getstr("3dlut.install")
                    else:
                        # Copy to user-selectable location
                        wx.CallAfter(
                            self.lut3d_create_handler,
                            None,
                            copy_from_path=self.lut3d_path,
                        )
                else:
                    # Need to create 3D LUT
                    wx.CallAfter(self.lut3d_create_handler, None)
            else:
                if getcfg("profile.install_scope") in ("l", "n"):
                    result = self.worker.authenticate(
                        "dispwin", lang.getstr("profile.install"), self
                    )
                    if result not in (True, None):
                        if isinstance(result, Exception):
                            show_result_dialog(result, parent=self)
                        return
                producer = self.worker.install_profile
                wargs = ()
                wkwargs = {
                    "profile_path": self.modaldlg.profile_path,
                    "skip_scripts": self.modaldlg.skip_scripts,
                }
                progress_msg = lang.getstr("profile.install")
            if producer:
                print("-" * 80)
                print(progress_msg)
                self.worker.interactive = False
                self.worker.start(
                    self.profile_finish_consumer,
                    producer,
                    wargs=wargs,
                    wkwargs=wkwargs,
                    parent=self,
                    progress_msg=progress_msg,
                    stop_timers=False,
                    fancy=False,
                )

    def profile_finish_consumer(
        self,
        result: None | tuple[str, str, str, str] | Exception = None,
    ) -> None:
        """Handle the result of the profile installation process.

        Args:
            result (None | tuple | Exception, optional): The result of the
                profile installation process. If an exception occurred, it will
                be passed as an instance of Exception. If the installation was
                successful, it will be a tuple containing the results of the
                profile installation methods (ArgyllCMS, colord, Oyranos, and
                profile loader). Defaults to None.
        """
        if isinstance(result, Exception):
            show_result_dialog(result, parent=self)
            if not getcfg("dry_run") and not isinstance(result, (Info, Warning)):
                return
        elif result:
            # Check all profile install methods
            argyll_install, colord_install, oy_install, loader_install = result
            all_good = (
                argyll_install in (None, True)
                and colord_install in (None, True)
                and oy_install in (None, True)
                and loader_install in (None, True)
            )
            some_good = (
                argyll_install is True
                or colord_install is True
                or oy_install is True
                or loader_install is True
            )
            linux = sys.platform not in ("darwin", "win32")
            if all_good:
                msg = lang.getstr("profile.install.success")
                icon = "dialog-information"
            elif some_good and linux:
                msg = lang.getstr("profile.install.warning")
                icon = "dialog-warning"
            else:
                msg = lang.getstr("profile.install.error")
                icon = "dialog-error"
            dlg = InfoDialog(
                self,
                msg=msg,
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, icon),
                show=False,
            )
            if not all_good and linux:
                sizer = wx.FlexGridSizer(0, 2, 8, 8)
                dlg.sizer3.Add(sizer, 1, flag=wx.TOP, border=12)
                for name, result_ in (
                    ("ArgyllCMS", argyll_install),
                    ("colord", colord_install),
                    ("Oyranos", oy_install),
                    (lang.getstr("profile_loader"), loader_install),
                ):
                    if result_ is not None:
                        if result_ is True:
                            icon = "checkmark"
                            result_ = lang.getstr("ok")
                        elif isinstance(result_, Warning):
                            icon = "dialog-warning"
                        else:
                            icon = "x"
                            if not result_:
                                result_ = lang.getstr("failure")
                        result_ = wrap(str(result_))
                        sizer.Add(
                            wx.StaticBitmap(dlg, -1, get_icon(16, icon)),
                            flag=wx.TOP,
                            border=2,
                        )
                        sizer.Add(wx.StaticText(dlg, -1, f"{name}: {result_}"))
                dlg.sizer0.SetSizeHints(dlg)
                dlg.sizer0.Layout()
            dlg.ok.SetDefault()
            dlg.ShowModalThenDestroy()
        if self.modaldlg.IsShown():
            self.modaldlg.EndModal(wx.ID_CLOSE)
        self.modaldlg.Destroy()
        # The C part of modaldlg will not be gone instantly, so we must
        # dereference it before we can delete the python attribute
        self.modaldlg = None
        del self.modaldlg
        if sys.platform == "darwin":
            # For some reason, the call to enable_menus() in Show()
            # sometimes isn't enough under Mac OS X (e.g. after calibrate &
            # profile)
            self.enable_menus()
        self.start_timers(True)
        setcfg("calibration.file.previous", None)

    def profile_info_close_handler(self, event: wx.Event) -> None:
        """Handle the close event for the profile information window.

        Args:
            event (wx.Event): The close event triggered by the window.
        """
        if getattr(self, "show_profile_info", None):
            # If the profile install dialog is shown, just hide info window
            self.profile_info[event.GetEventObject().profileID].Hide()
            self.show_profile_info.SetValue(False)
        else:
            # Remove the frame from the hash table
            self.profile_info.pop(event.GetEventObject().profileID)
            # Closes the window
            event.Skip()

    def profile_info_handler(
        self, event: None | wx.Event = None, profile: None | ICCProfile = None
    ) -> None:
        """Show profile information window.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
            profile (ICCProfile, optional): The profile to show information
                for. Defaults to None, in which case the user is prompted to
                    select a profile.
        """
        if not ProfileInfoFrame:
            wx.Bell()
            return

        if profile:
            pass
        elif event and event.GetEventObject() is getattr(
            self, "show_profile_info", False
        ):
            # Use the profile that was requested to be installed
            profile = self.modaldlg.profile
        else:
            profile = self.select_profile(
                title=lang.getstr("profile.info"),
                check_profile_class=False,
                prefer_current_profile=True,
                ignore_current_profile=event
                and event.GetEventObject() is not self.profile_info_btn,
            )
        if not profile:
            return
        id_ = profile.calculate_id(False) if profile.ID == "\0" * 16 else profile.ID
        show = (
            not getattr(self, "show_profile_info", None)
            or self.show_profile_info.GetValue()
        )
        if show:
            if id_ not in self.profile_info:
                # Create profile info window and store in hash table
                self.profile_info[id_] = ProfileInfoFrame(None, -1)
                self.profile_info[id_].Unbind(wx.EVT_CLOSE)
                self.profile_info[id_].Bind(
                    wx.EVT_CLOSE, self.profile_info_close_handler
                )
            if (
                not self.profile_info[id_].profile
                or self.profile_info[id_].profile.calculate_id(False) != id_
            ):
                # Load profile if info window has no profile or ID is different
                self.profile_info[id_].profileID = id_
                self.profile_info[id_].LoadProfile(profile)
        if self.profile_info.get(id_):
            if self.profile_info[id_].IsIconized() and show:
                self.profile_info[id_].Restore()
            else:
                self.profile_info[id_].Show(show)
            if show:
                self.profile_info[id_].Raise()

    def get_commands(self) -> list[str]:
        """Get a list of commands that this application can process.

        Returns:
            list[str]: List of command strings that this application can
                process.
        """
        return [
            *self.get_common_commands(),
            "3DLUT-maker [create filename]",
            "calibrate",
            "calibrate-profile",
            "create-colorimeter-correction",
            "create-profile [filename]",
            "curve-viewer [filename]",
            f"{APPNAME} [filename]",
            "enable-spyder2",
            "import-colorimeter-corrections [filename...]",
            "install-profile [filename]",
            "load <filename>",
            "measure",
            "measure-uniformity",
            "measurement-report [filename]",
            "profile",
            "profile-info [filename]",
            "report-calibrated",
            "report-uncalibrated",
            "set-argyll-dir [dirname]",
            "synthprofile [filename]",
            "testchart-editor [filename | create filename]",
            "verify-calibration",
        ]

    def process_data(self, data: list[str]) -> str:
        """Process data.

        Args:
            data (list[str]): List of strings containing the command and its
                arguments.

        Returns:
            str: Response indicating the result of the processing.
        """
        if not self.IsShownOnScreen() and data[0] != "measure":
            # If we were hidden, perform necessary cleanup in case the
            # measurement window is shown and we're not starting measurements
            if isinstance(getattr(self, "_measureframe_subprocess", None), sp.Popen):
                self._measureframe_subprocess.terminate()
            elif self.measureframe.IsShown():
                self.measureframe.close_handler(None)
            else:
                return "busy"
        response = "ok"
        if data[0] == "3DLUT-maker" and (
            len(data) == 1 or (len(data) == 3 and data[1] == "create")
        ):
            # 3D LUT maker
            if len(data) == 3:
                self.lut3d_create_handler(None, data[-1])
            else:
                self.tab_select_handler(self.lut3d_settings_btn, True)
        elif data[0] == "curve-viewer" and len(data) < 3:
            # Curve viewer
            profile = None
            if len(data) == 2:
                path = data[1]
                if not os.path.isfile(path) and not os.path.isabs(path):
                    path = get_data_path(path)
                if not path:
                    return "fail"
                try:
                    profile = ICCProfile(path)
                except (OSError, ICCProfileInvalidError):
                    return "fail"
            wx.CallAfter(self.init_lut_viewer, profile=profile, show=True)
        elif data[0] == "profile-info" and len(data) < 3:
            # Profile info
            profile = None
            if len(data) == 2:
                path = data[1]
                if not os.path.isfile(path) and not os.path.isabs(path):
                    path = get_data_path(path)
                if not path:
                    return "fail"
                try:
                    profile = ICCProfile(path)
                except (OSError, ICCProfileInvalidError):
                    return "fail"
            wx.CallAfter(self.profile_info_handler, profile=profile)
        elif data[0] == "synthprofile" and len(data) < 3:
            # Synthetic profile creator
            self.synthicc_create_handler(None)
            if len(data) == 2:
                response = self.synthiccframe.process_data(data)
        elif data[0] == "testchart-editor" and (
            len(data) < 3 or (len(data) == 3 and data[1] == "create")
        ):
            # Testchart editor
            if len(data) == 2:
                path = data[1]
                if not os.path.isfile(path) and not os.path.isabs(path):
                    path = get_data_path(path)
                if not path:
                    return "fail"
            else:
                path = None
            if not hasattr(self, "tcframe"):
                self.init_tcframe(path=path)
                setcfg("tc.show", 1)
                self.tcframe.Show()
            else:
                if self.tcframe.IsIconized():
                    self.tcframe.Restore()
                else:
                    self.tcframe.Show()
                if path:
                    self.tcframe.tc_load_cfg_from_ti1(path=path)
            self.tcframe.Raise()
            if len(data) == 3:
                # Create testchart
                response = self.tcframe.process_data(data)
        elif (data[0] == APPNAME and len(data) < 3) or (
            data[0] == "load" and len(data) == 2
        ):
            # Main window
            if self.IsIconized():
                self.Restore()
            self.Raise()
            if len(data) == 2:
                path = data[1]
                if not os.path.isfile(path) and not os.path.isabs(path):
                    path = get_data_path(path)
                if not path:
                    return "fail"
                self.droptarget.OnDropFiles(0, 0, [path])
        elif data[0] == "calibrate" and len(data) == 1:
            # Calibrate
            wx.CallAfter(
                self.calibrate_btn_handler,
                CustomEvent(wx.EVT_BUTTON.evtType[0], self.calibrate_btn),
            )
        elif data[0] == "calibrate-profile" and len(data) == 1:
            # Calibrate & profile
            wx.CallAfter(
                self.calibrate_and_profile_btn_handler,
                CustomEvent(wx.EVT_BUTTON.evtType[0], self.calibrate_and_profile_btn),
            )
        elif data[0] == "create-profile" and len(data) < 3:
            profile_path = data[1] if len(data) == 2 else None
            wx.CallAfter(self.create_profile_handler, None, path=profile_path)
        elif data[0] == "import-colorimeter-corrections":
            wx.CallAfter(
                self.import_colorimeter_corrections_handler, None, paths=data[1:]
            )
        elif data[0] == "install-profile" and len(data) < 3:
            if len(data) == 2:
                wx.CallAfter(
                    self.install_profile_handler,
                    profile_path=data[1],
                    install_3dlut=False,
                )
            else:
                wx.CallAfter(self.select_install_profile_handler, None)
        elif data[0] == "measure" and len(data) == 1:
            # Start measurement
            if getattr(self, "pending_function", None):
                if isinstance(
                    getattr(self, "_measureframe_subprocess", None), sp.Popen
                ):
                    p = self._measureframe_subprocess
                    self._measureframe_subprocess = Dummy()
                    self._measureframe_subprocess.returncode = 255
                    p.terminate()
                else:
                    self.worker.wrapup(False)
                    self.HideAll()
                    self.call_pending_function()
            else:
                response = "fail"
        elif data[0] == "measurement-report" and len(data) < 3:
            # Measurement report
            if len(data) == 2:
                wx.CallAfter(
                    self.measurement_report_handler,
                    CustomEvent(wx.EVT_BUTTON.evtType[0], self.measurement_report_btn),
                    path=data[1],
                )
            else:
                self.tab_select_handler(self.mr_settings_btn, True)
        elif data[0] == "profile" and len(data) == 1:
            # Profile
            wx.CallAfter(
                self.profile_btn_handler,
                CustomEvent(wx.EVT_BUTTON.evtType[0], self.profile_btn),
            )
        elif data[0] == "refresh" and len(data) == 1:
            # Refresh main window
            self.update_displays()
            self.update_controls()
            self.update_menus()
            if hasattr(self, "tcframe"):
                self.tcframe.tc_update_controls()
        elif data[0] == "restore-defaults":
            # Restore defaults
            wx.CallAfter(self.restore_defaults_handler, include=data[1:])
        elif data[0] == "setlanguage" and len(data) == 2:
            setcfg("lang", data[1])
            menuitem = self.menubar.FindItemById(lang.LDICT[lang.getcode()].menuitem_id)
            event = CustomEvent(wx.EVT_MENU.typeId, menuitem)
            wx.CallAfter(self.set_language_handler, event)
        elif (
            data[0]
            in (
                "create-colorimeter-correction",
                "enable-spyder2",
                "measure-uniformity",
                "report-calibrated",
                "report-uncalibrated",
                "verify-calibration",
            )
        ) and len(data) == 1:
            wx.CallAfter(getattr(self, data[0].replace("-", "_") + "_handler"), True)
        elif data[0] == "set-argyll-dir" and len(data) <= 2:
            if getattr(self.worker, "thread", None) and self.worker.thread.is_alive():
                return "blocked"
            if len(data) == 2:
                setcfg("argyll.dir", data[1])
                # Always write cfg directly after setting Argyll directory so
                # subprocesses that read the configuration will use the right
                # executables
                writecfg()
                wx.CallAfter(self.check_update_controls, True)
            else:
                wx.CallAfter(self.set_argyll_bin_handler, True)
        else:
            response = "invalid"
        return response

    def observer_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the observer control.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        observer = self.observers_ba.get(self.observer_ctrl.GetStringSelection())
        setcfg("observer", observer)

    def output_levels_handler(self, event: wx.Event) -> None:
        """Handle changes to output levels settings.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        auto = self.output_levels_auto.GetValue()
        setcfg("patterngenerator.detect_video_levels", int(auto))
        use_video_levels = self.output_levels_limited_range.GetValue()
        setcfg("patterngenerator.use_video_levels", int(use_video_levels))
        self.update_use_video_lut()

    def init_lut_viewer(
        self,
        event: None | wx.Event = None,
        profile: None | ICCProfile = None,
        show: None | bool = None,
    ) -> None:
        """Initialize the LUT viewer.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
            profile (ICCProfile, optional): The profile to load in the LUT
                viewer. If None, the current calibration profile will be used.
            show (bool, optional): Whether to show or hide the LUT viewer. If
                None, it will be determined based on the current state of the
                viewer and the configuration setting.
        """
        debug_print(
            "[D] init_lut_viewer",
            profile.getDescription() if profile else None,
            "show:",
            show,
        )
        if not LUTFrame:
            return
        lut_viewer = getattr(self, "lut_viewer", None)
        if not lut_viewer:
            self.lut_viewer = LUTFrame(None, -1)
            self.lut_viewer.client.worker = self.worker
            self.lut_viewer.update_controls()
            self.lut_viewer.Bind(
                wx.EVT_CLOSE, self.lut_viewer_close_handler, self.lut_viewer
            )
        if not profile and not hasattr(self, "current_cal"):
            path = getcfg("calibration.file", False)
            if path:
                name, ext = os.path.splitext(path)
                if ext.lower() in (".icc", ".icm"):
                    try:
                        profile = ICCProfile(path)
                    except (OSError, ICCProfileInvalidError):
                        msg = lang.getstr("profile.invalid") + "\n" + path
                        if event or not lut_viewer:
                            show_result_dialog(Error(msg), self)
                        else:
                            print(msg)
                        profile = None
                else:
                    profile = cal_to_fake_profile(path)
            else:
                profile = get_display_profile() or False
        if show is None:
            show = not self.lut_viewer.IsShownOnScreen()
        debug_print(
            "[D] init_lut_viewer (2)",
            profile.getDescription() if profile else None,
            "show:",
            show,
        )
        self.show_lut_handler(profile=profile, show=show)

    def lut_viewer_load_lut(
        self,
        event: None | wx.Event = None,
        profile: None | ICCProfile = None,
        force_draw: bool = False,
    ) -> None:
        """Load a LUT into the LUT viewer.

        Args:
            event (None | wx.Event, optional): The event that triggered this
                handler.
            profile (None | ICCProfile, optional): The profile to load in the
                LUT viewer. If None, the current calibration profile will be
                used.
            force_draw (bool, optional): If True, the LUT viewer will be forced
                to redraw even if it is already shown. Defaults to False.
        """
        debug_print(
            "[D] lut_viewer_load_lut",
            profile.getDescription() if profile else None,
            "force_draw:",
            force_draw,
        )
        if LUTFrame:
            self.current_cal = profile
        if getattr(self, "lut_viewer", None) and (
            self.lut_viewer.IsShownOnScreen() or force_draw
        ):
            self.lut_viewer.load_lut(profile)

    def show_lut_handler(
        self,
        event: wx.Event = None,
        profile: None | ICCProfile = None,
        show: None | bool = None,
    ) -> None:
        """Show or hide the LUT viewer.

        Args:
            event (None | wx.Event, optional): The event that triggered this
                handler.
            profile (None | ICCProfile, optional): The profile to load in the
                LUT viewer.
            show (None | bool, optional): Whether to show or hide the LUT
                viewer. If None, it will be determined based on the current
                state of the viewer and the configuration setting.
        """
        debug_print(
            "[D] show_lut_handler",
            profile.getDescription() if profile else None,
            "show:",
            show,
        )
        if show is None:
            show = bool(
                (
                    hasattr(self, "show_lut")
                    and self.show_lut
                    and self.show_lut.GetValue()
                )
                or (not hasattr(self, "show_lut") or not self.show_lut)
            )
        setcfg("lut_viewer.show", int(show))
        if not profile and hasattr(self, "current_cal"):
            profile = self.current_cal
        if show:
            self.lut_viewer_load_lut(event, profile, force_draw=True)
        if getattr(self, "lut_viewer", None):
            self.menuitem_show_lut.Check(show)
            if self.lut_viewer.IsIconized() and show:
                self.lut_viewer.Restore()
            else:
                self.lut_viewer.Show(show)
            if show:
                self.lut_viewer.Raise()

    def lut_viewer_close_handler(self, event: wx.Event = None) -> None:
        """Handle the close event for the LUT viewer.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        setcfg("lut_viewer.show", 0)
        self.lut_viewer.Hide()
        self.menuitem_show_lut.Check(False)
        if hasattr(self, "show_lut") and self.show_lut:
            self.show_lut.SetValue(self.lut_viewer.IsShownOnScreen())

    def show_advanced_options_handler(self, event: wx.Event = None) -> None:
        """Show or hide advanced calibration settings.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        show_advanced_options = bool(getcfg("show_advanced_options"))
        if event:
            show_advanced_options = not show_advanced_options
            setcfg("show_advanced_options", int(show_advanced_options))
        self.panel.Freeze()
        self.menuitem_show_advanced_options.Check(show_advanced_options)
        self.menuitem_advanced_options.Enable(show_advanced_options)
        for ctrl in (  # Calibration options
            self.black_luminance_label,
            self.black_luminance_ctrl,
            # Profiling options
            self.black_point_compensation_cb,
            self.profile_type_label,
            self.profile_type_ctrl,
            self.gamap_btn,
            # Patch sequence
            self.testchart_patch_sequence_label,
            self.testchart_patch_sequence_ctrl,
        ):
            ctrl.GetContainingSizer().Show(ctrl, show_advanced_options)
        self.show_display_delay_ctrls()
        self.show_ffp_ctrls()
        self.show_output_levels_ctrls()
        self.whitepoint_colortemp_locus_label.Show(
            show_advanced_options and self.whitepoint_ctrl.GetSelection() != 2
        )
        self.whitepoint_colortemp_locus_ctrl.Show(
            show_advanced_options and self.whitepoint_ctrl.GetSelection() != 2
        )
        self.black_luminance_textctrl.Show(
            show_advanced_options and bool(getcfg("calibration.black_luminance", False))
        )
        self.black_luminance_textctrl_label.Show(
            show_advanced_options and bool(getcfg("calibration.black_luminance", False))
        )
        self.lut3d_show_controls()
        self.mr_show_trc_controls()
        if event:
            self.show_observer_ctrl()
            self.show_trc_controls()
        self.calpanel.Layout()
        self.calpanel.Refresh()
        self.panel.Thaw()
        if event:
            self.set_size(True)
        self.update_scrollbars()

    def show_display_delay_ctrls(self) -> None:
        """Show or hide the display delay controls based on configuration."""
        show_advanced_options = bool(getcfg("show_advanced_options"))
        not_untethered = config.get_display_name(None, True) != "Untethered"
        for ctrl in (
            self.override_min_display_update_delay_ms,
            self.min_display_update_delay_ms,
            self.min_display_update_delay_ms_label,
        ):
            ctrl.GetContainingSizer().Show(
                ctrl, show_advanced_options and not_untethered
            )
        self.override_display_settle_time_mult.Show(
            show_advanced_options
            and getcfg("argyll.version") >= "1.7"
            and not_untethered
        )
        self.display_settle_time_mult.Show(
            show_advanced_options
            and getcfg("argyll.version") >= "1.7"
            and not_untethered
        )

    def show_ffp_ctrls(self) -> None:
        """Show or hide the full field pattern controls based on configuration."""
        # Full field pattern insertion
        show_advanced_options = bool(getcfg("show_advanced_options"))
        display_name = config.get_display_name(None, True)
        ffp_show = show_advanced_options and (
            (
                display_name == "Prisma"
                and not DEFAULTS["patterngenerator.prisma.argyll"]
            )
            or display_name == "Resolve"
            or (
                display_name == "madVR"
                and (
                    sys.platform != "win32"
                    or not getcfg("madtpg.native")
                    or bool(self.worker.argyll_virtual_display)
                )
            )
        )
        for ctrl in (
            self.ffp_insertion,
            self.ffp_insertion_interval_label,
            self.ffp_insertion_interval,
            self.ffp_insertion_interval_s_label,
            self.ffp_insertion_duration_label,
            self.ffp_insertion_duration,
            self.ffp_insertion_duration_s_label,
            self.ffp_insertion_level_label,
            self.ffp_insertion_level,
            self.ffp_insertion_level_percentage_label,
        ):
            ctrl.GetContainingSizer().Show(ctrl, ffp_show)

    def show_output_levels_ctrls(self) -> None:
        """Show or hide the output levels controls based on configuration."""
        show_levels_config = config.get_display_name(None, True) not in (
            "madVR",
            "Untethered",
        ) and bool(getcfg("show_advanced_options"))
        for ctrl in (
            self.output_levels_label,
            self.output_levels_auto,
            self.output_levels_full_range,
            self.output_levels_limited_range,
        ):
            ctrl.Show(show_levels_config)

    def show_observer_ctrl(self) -> None:
        """Show or hide the observer control based on configuration."""
        self.panel.Freeze()
        show = bool(
            (getcfg("calibration.interactive_display_adjustment") or getcfg("trc"))
            and getcfg("show_advanced_options")
            and self.worker.instrument_can_use_nondefault_observer()
        )
        self.observer_label.Show(show)
        self.observer_ctrl.Show(show)
        self.calpanel.Layout()
        self.panel.Thaw()
        self.update_scrollbars()

    def update_observer_ctrl(self) -> None:
        """Update the observer control with available observers."""
        self.observer_ctrl.SetStringSelection(self.observers_ab[getcfg("observer")])

    def install_profile_scope_handler(self, event: wx.Event) -> None:
        """Handle the selection of the profile installation scope.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if self.install_profile_systemwide.GetValue():
            setcfg("profile.install_scope", "l")
            if hasattr(self.modaldlg.ok, "SetAuthNeeded"):
                self.modaldlg.ok.SetAuthNeeded(True)
        elif (
            sys.platform == "darwin"
            and os.path.isdir("/Network/Library/ColorSync/Profiles")
            and self.install_profile_network.GetValue()
        ):
            setcfg("profile.install_scope", "n")
        elif self.install_profile_user.GetValue():
            setcfg("profile.install_scope", "u")
            if hasattr(self.modaldlg.ok, "SetAuthNeeded"):
                self.modaldlg.ok.SetAuthNeeded(False)
        self.modaldlg.buttonpanel.Layout()

    def start_timers(self, wrapup: bool = False) -> None:
        """Start timers for profile name updates and keydown checks.

        Args:
            wrapup (bool, optional): If True, wrap up the worker before
                starting the timers. Defaults to False.
        """
        if wrapup:
            self.worker.wrapup(False)
        if not self.update_profile_name_timer.IsRunning():
            self.update_profile_name_timer.Start(1000)
        if not self.check_keydown_timer.IsRunning():
            self.check_keydown_timer.Start(250)
        if (
            sys.platform == "darwin"
            and getcfg("profile_loader.macos_reapply_watch")
            and not self.lut_watch_timer.IsRunning()
        ):
            interval = max(
                1, int(getcfg("profile_loader.macos_reapply_watch_interval"))
            )
            self.lut_watch_timer.Start(interval * 1000)

    def stop_timers(self) -> None:
        """Stop timers for profile name updates and keydown checks."""
        self.update_profile_name_timer.Stop()
        self.check_keydown_timer.Stop()
        self.lut_watch_timer.Stop()

    def check_lut_clobbered(self, event: "wx.TimerEvent | None" = None) -> None:
        """Re-load calibration if macOS has clobbered the VideoLUT (issue #694).

        Idle backstop to the in-session recovery in worker.exec_cmd: runs only on
        macOS and only while idle (no measurement in progress, so it can never
        disturb a running calibration). Any display whose VideoLUT has gone black
        is detected via CoreGraphics and re-loaded with ``dispwin -L``.
        """
        if sys.platform != "darwin" or self.worker.is_working():
            return
        if not getcfg("profile_loader.macos_reapply_watch"):
            return
        dispwin = get_argyll_util("dispwin")
        if dispwin:
            reload_black_videoluts(dispwin, log=self.worker.log)

    def synthicc_create_handler(self, event: wx.Event) -> None:
        """Assign and initialize the synthetic ICC creation window."""
        if not getattr(self, "synthiccframe", None):
            self.init_synthiccframe()
        if self.synthiccframe.IsShownOnScreen():
            if self.synthiccframe.IsIconized():
                self.synthiccframe.Restore()
            self.synthiccframe.Raise()
        else:
            self.synthiccframe.Show(not self.synthiccframe.IsShownOnScreen())

    def tab_select_handler(
        self,
        event: wx.Event,
        update_main_controls: bool = False,
    ) -> None:
        """Handle tab selection in the calibration panel.

        Args:
            event (wx.Event): The event that triggered this handler.
            update_main_controls (bool): Whether to update the main controls
                after changing the tab. Defaults to False.
        """
        if hasattr(event, "EventObject") and not event.EventObject.IsEnabled():
            return
        self.panel.Freeze()
        btn2tab = {
            self.display_instrument_btn: self.display_instrument_panel,
            self.calibration_settings_btn: self.calibration_settings_panel,
            self.profile_settings_btn: self.profile_settings_panel,
            self.lut3d_settings_btn: self.lut3d_settings_panel,
            self.mr_settings_btn: self.mr_settings_panel,
        }
        for btn in btn2tab:
            tab = btn2tab[btn]
            if event.GetId() == btn.Id:
                if tab is self.mr_settings_panel and not tab.IsShown():
                    self.mr_update_controls()
                elif tab is self.lut3d_settings_panel and not tab.IsShown():
                    self.set_profile("output")
                    self.lut3d_show_trc_controls()
                if hasattr(self, "install_profile_btn"):
                    if tab is self.lut3d_settings_panel:
                        self.install_profile_btn.SetToolTipString(
                            lang.getstr("3dlut.install")
                        )
                    else:
                        self.install_profile_btn.SetToolTipString(
                            lang.getstr("profile.install")
                        )
                tab.Show()
                btn._pressed = True
                btn._SetState(platebtn.PLATE_PRESSED)
            else:
                tab.Hide()
                btn._pressed = False
                btn._SetState(platebtn.PLATE_NORMAL)
        self.calpanel.Layout()
        if isinstance(event, wx.Event) or update_main_controls:
            self.update_main_controls()
        self.panel.Thaw()
        self.update_scrollbars()
        self.calpanel.Layout()
        self.calpanel.Update()

    def colorimeter_correction_matrix_ctrl_handler(
        self, event: wx.Event, path: None | str = None
    ) -> None:
        """Handle the colorimeter correction matrix control.

        Args:
            event (wx.Event): The event that triggered this handler.
            path (str, optional): The path to the colorimeter correction matrix
                file. If not provided, the current configuration will be used
                to determine the path.
        """
        measurement_mode = getcfg("measurement_mode")
        if event and event.GetId() == self.colorimeter_correction_matrix_ctrl.GetId():
            ccmx = getcfg("colorimeter_correction_matrix_file").split(":", 1)
            if self.colorimeter_correction_matrix_ctrl.GetSelection() == 0:
                # Off
                ccmx = ["", ""]
            elif self.colorimeter_correction_matrix_ctrl.GetSelection() == 1:
                # Auto
                ccmx = ["AUTO", ""]
            else:
                path = self.ccmx_item_paths[
                    self.colorimeter_correction_matrix_ctrl.GetSelection() - 2
                ]
                ccmx = ["", path]
            setcfg("colorimeter_correction_matrix_file", ":".join(ccmx))
            self.update_colorimeter_correction_matrix_ctrl_items()
        else:
            if not path:
                ccmx = getcfg("colorimeter_correction_matrix_file").split(":", 1)
                default_dir, default_file = get_verified_path(None, ccmx.pop())
                dlg = wx.FileDialog(
                    self,
                    lang.getstr("colorimeter_correction_matrix_file.choose"),
                    defaultDir=(
                        default_dir if default_file else config.get_argyll_data_dir()
                    ),
                    defaultFile=default_file,
                    wildcard=lang.getstr("filetype.ccmx") + "|*.ccmx;*.ccss",
                    style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
                )
                dlg.Center(wx.BOTH)
                if dlg.ShowModal() == wx.ID_OK:
                    path = dlg.GetPath()
                dlg.Destroy()
            if path:
                if (
                    getcfg("colorimeter_correction_matrix_file").split(":")[0] != "AUTO"
                    or path not in self.ccmx_cached_paths
                ):
                    setcfg("colorimeter_correction_matrix_file", ":" + path)
                self.update_colorimeter_correction_matrix_ctrl_items(
                    warn_on_mismatch=True
                )
        if measurement_mode != getcfg("measurement_mode"):
            # Check if black point correction should be turned on
            self.measurement_mode_ctrl_handler()

    def colorimeter_correction_info_handler(
        self, event: wx.Event, ccxx: None | str = None
    ) -> None:
        """Plot spectra or matrix.

        Args:
            event (wx.Event): The event that triggered this handler.
            ccxx (None | str, optional): The path to the colorimeter correction
                matrix file. If not provided, the current configuration will be
                used to determine the path.
        """
        if not CCXXPlot:
            wx.Bell()
            return

        if not ccxx:
            ccxx = getcfg("colorimeter_correction_matrix_file").split(":", 1)
            if len(ccxx) < 2 or not os.path.isfile(ccxx[1]):
                wx.Bell()
                return

            ccxx = ccxx[1]

        try:
            cgats = CGATS(ccxx)
        except Exception as exception:
            show_result_dialog(exception, self)
            return

        if 0 not in cgats:
            wx.Bell()
            return

        key = md5(bytes(cgats)).digest()  # noqa: S324
        plotwindow = self.ccxx_plot_windows.get(key)
        if not plotwindow:
            plotwindow = CCXXPlot(self, cgats, self.worker)
            self.ccxx_plot_windows[key] = plotwindow

        plotwindow.Show()
        plotwindow.Raise()

    def colorimeter_correction_web_handler(self, event: wx.Event) -> None:
        """Check the web for cccmx or ccss files.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        filetype = "ccss,ccmx" if self.worker.instrument_supports_ccss() else "ccmx"
        params = {
            "get": True,
            "type": filetype,
            "manufacturer_id": self.worker.get_display_edid().get(
                "manufacturer_id", ""
            ),
            "display": self.worker.get_display_name(False, True) or "Unknown",
            "instrument": self.worker.get_instrument_name() or "Unknown",
            "json": 1,
        }
        self.worker.interactive = False
        self.worker.start(
            colorimeter_correction_web_check_choose,
            http_request,
            ckwargs={"parent": self},
            wargs=(
                self,
                f"colorimetercorrections.{DOMAIN}",
                "GET",
                "/index.php",
                params,
            ),
            progress_msg=lang.getstr("colorimeter_correction.web_check"),
            stop_timers=False,
            cancelable=False,
            show_remaining_time=False,
            fancy=False,
        )

    def create_colorimeter_correction_handler(
        self,
        event: None | wx.Event = None,
        paths: None | list[str] = None,
        luminance: None | float = None,
    ) -> None:
        """Create a CCSS or CCMX file from one or more .ti3 files.

        Atleast one of the ti3 files must be a measured with a spectrometer.

        Args:
            event (wx.Event): The event that triggered this handler.
            paths (list, optional): List of paths to .ti3 files.
            luminance (float, optional): Optional luminance value to use for
                the colorimeter correction. If not provided, the luminance will
                be determined from the reference instrument's measurement.
        """
        parent = self if event else None
        if wx.VERSION >= (3,):
            id_measure_reference = wx.Window.NewControlId()
            id_measure_colorimeter = wx.Window.NewControlId()
        else:
            id_measure_reference = IdFactory.NewId()
            id_measure_colorimeter = IdFactory.NewId()
        if not paths:
            dlg = ConfirmDialog(
                parent,
                title=lang.getstr("colorimeter_correction.create"),
                msg=lang.getstr("colorimeter_correction.create.info"),
                ok=lang.getstr("colorimeter_correction.create"),
                cancel=lang.getstr("cancel"),
                # alt=lang.getstr("browse"),
                bitmap=get_icon(32, "dialog-information"),
                wrap=90,
            )
            boxsizer = wx.BoxSizer(wx.HORIZONTAL)
            dlg.sizer3.Add(boxsizer, 1, flag=wx.TOP | wx.EXPAND, border=12)
            warning_icon = wx.StaticBitmap(dlg, -1, get_icon(16, "dialog-warning"))
            boxsizer.Add(warning_icon)
            warning_text = wx.StaticText(
                dlg, -1, wrap(lang.getstr("colorimeter_correction.create.warning"), 86)
            )
            warning_text.ForegroundColour = "#F07F00"
            boxsizer.Add(warning_text, flag=wx.LEFT, border=8)
            # Colorimeter correction type
            # We deliberately don't use RadioBox because there's no way to
            # set the correct background color (this matters under MSWindows
            # where the dialog background is usually white) unless you use
            # Phoenix.
            boxsizer = wx.StaticBoxSizer(
                wx.StaticBox(dlg, -1, lang.getstr("type")), wx.VERTICAL
            )
            dlg.sizer3.Add(boxsizer, 1, flag=wx.TOP | wx.EXPAND, border=12)
            if sys.platform not in ("darwin", "win32"):
                boxsizer.Add((1, 8))
            dlg.correction_type_matrix = wx.RadioButton(
                dlg, -1, lang.getstr("matrix"), style=wx.RB_GROUP
            )
            hsizer = wx.BoxSizer(wx.HORIZONTAL)
            boxsizer.Add(hsizer, flag=wx.ALL | wx.EXPAND, border=4)
            hsizer.Add(dlg.correction_type_matrix)
            dlg.four_color_matrix = wx.CheckBox(
                dlg, -1, lang.getstr("ccmx.use_four_color_matrix_method")
            )
            dlg.four_color_matrix.SetValue(
                bool(getcfg("ccmx.use_four_color_matrix_method"))
            )
            hsizer.Add(dlg.four_color_matrix, flag=wx.LEFT, border=8)
            dlg.correction_type_spectral = wx.RadioButton(
                dlg,
                -1,
                lang.getstr("spectral") + " (i1 DisplayPro, "
                "ColorMunki "
                "Display, Spyder4/5)",
            )
            boxsizer.Add(dlg.correction_type_spectral, flag=wx.ALL, border=4)
            {
                "matrix": dlg.correction_type_matrix,
                "spectral": dlg.correction_type_spectral,
            }.get(getcfg("colorimeter_correction.type"), "matrix").SetValue(True)
            # Get instruments
            reference_instruments = []
            colorimeters = []
            for instrument in self.worker.instruments:
                if instruments.get(instrument, {}).get("spectral"):
                    reference_instruments.append(instrument)
                else:
                    colorimeters.append(instrument)
            # Reference instrument
            boxsizer = wx.StaticBoxSizer(
                wx.StaticBox(
                    dlg,
                    -1,
                    "{} ({})".format(
                        lang.getstr("instrument"), lang.getstr("reference")
                    ),
                ),
                wx.VERTICAL,
            )
            dlg.sizer3.Add(boxsizer, 1, flag=wx.TOP | wx.EXPAND, border=12)
            if sys.platform not in ("darwin", "win32"):
                boxsizer.Add((1, 8))
            hsizer = wx.BoxSizer(wx.HORIZONTAL)
            boxsizer.Add(hsizer, flag=wx.EXPAND)
            dlg.reference_instrument = wx.Choice(dlg, -1, choices=reference_instruments)
            hsizer.Add(
                dlg.reference_instrument,
                1,
                flag=wx.LEFT | wx.TOP | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL,
                border=4,
            )
            hsizer.Add(
                wx.StaticText(dlg, -1, lang.getstr("measurement_mode")),
                flag=wx.LEFT | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL,
                border=8,
            )
            dlg.measurement_mode_reference = wx.Choice(dlg, -1, choices=[])

            def set_ok_btn_state() -> None:
                """Set the state of the OK button based on the selected files."""
                dlg.ok.Enable(
                    bool(
                        getcfg("last_reference_ti3_path", False)
                        and os.path.isfile(getcfg("last_reference_ti3_path"))
                        and (
                            (
                                getcfg("last_colorimeter_ti3_path", False)
                                and os.path.isfile(getcfg("last_colorimeter_ti3_path"))
                            )
                            or dlg.correction_type_spectral.GetValue()
                        )
                    )
                )

            def check_last_ccxx_ti3(event: wx.Event) -> None:
                """Check the last ti3 file for the colorimeter or reference.

                Args:
                    event (wx.Event): The event that triggered this handler.
                """
                cfgname = "colorimeter_correction.measurement_mode"
                if event.GetId() in (
                    dlg.instrument.Id,
                    dlg.measurement_mode.Id,
                    dlg.observer_ctrl.Id,
                    dlg.colorimeter_ti3.textControl.Id,
                ):
                    name = "colorimeter"
                    instrument = dlg.instrument.GetStringSelection()
                    measurement_mode = self.get_ccxx_measurement_modes(
                        instrument, True
                    ).get(dlg.measurement_mode.GetStringSelection())
                    observer_ctrl = dlg.observer_ctrl
                else:
                    name = "reference"
                    cfgname += "." + name
                    instrument = dlg.reference_instrument.GetStringSelection()
                    if hasattr(dlg, "modes_ab"):
                        measurement_mode = dlg.modes_ab["spect"][
                            dlg.measurement_mode_reference.GetSelection()
                        ]
                    else:
                        measurement_mode = None
                    observer_ctrl = dlg.observer_reference_ctrl
                if DEBUG or VERBOSE >= 2:
                    print("check_last_ccxx_ti3", name)
                    print("instrument =", instrument)
                    print("measurement_mode =", measurement_mode)
                if event.GetId() == getattr(dlg, f"{name}_ti3").textControl.Id:
                    setcfg(
                        f"last_{name}_ti3_path",
                        getattr(dlg, f"{name}_ti3").GetValue(),
                    )
                ti3 = getcfg(f"last_{name}_ti3_path", False)
                if DEBUG or VERBOSE >= 2:
                    print(f"last_{name}_ti3_path =", ti3)
                if ti3:
                    if os.path.isfile(ti3):
                        try:
                            cgats = CGATS(ti3)
                        except (OSError, CGATSError) as exception:
                            show_result_dialog(exception, dlg)
                            cgats = CGATS()
                        cgats_instrument = cgats.queryv1("TARGET_INSTRUMENT")
                        if cgats_instrument:
                            cgats_instrument = get_canonical_instrument_name(
                                cgats_instrument
                            )
                        if DEBUG or VERBOSE >= 2:
                            print("cgats_instrument =", cgats_instrument)
                        if name == "reference":
                            if getcfg(cfgname + ".projector"):
                                cgats_measurement_mode = "p"
                            else:
                                cgats_measurement_mode = getcfg(cfgname)
                        else:
                            cgats_measurement_mode = get_cgats_measurement_mode(
                                cgats, cgats_instrument
                            )
                        if cgats_measurement_mode:
                            instrument_features = self.worker.get_instrument_features(
                                instrument
                            )
                            if instrument_features.get("adaptive_mode") and getcfg(
                                cfgname + ".adaptive"
                            ):
                                cgats_measurement_mode += "V"
                            if (
                                instrument_features.get("highres_mode")
                                and cgats.queryv1("SPECTRAL_BANDS") is not None
                                and cgats.queryv1("SPECTRAL_BANDS") > 36
                            ):
                                cgats_measurement_mode += "H"
                        if DEBUG or VERBOSE >= 2:
                            print("cgats_measurement_mode =", cgats_measurement_mode)
                        cgats_observer = cgats.queryv1("OBSERVER")
                        if not cgats_observer:
                            cgats_observer = DEFAULTS["observer"].encode("utf-8")
                        if event.GetId() == dlg.reference_ti3.textControl.Id:
                            setcfg(
                                "colorimeter_correction.observer.reference",
                                cgats_observer.decode("utf-8"),
                            )
                            observer_ctrl.SetStringSelection(
                                self.observers_ab[
                                    getcfg("colorimeter_correction.observer.reference")
                                ]
                            )
                        if self.worker.instrument_can_use_nondefault_observer(
                            instrument
                        ):
                            observer = self.observers_ba[
                                observer_ctrl.GetStringSelection()
                            ]
                        else:
                            observer = DEFAULTS["observer"]
                        if DEBUG or VERBOSE >= 2:
                            print("observer =", observer)
                        if DEBUG or VERBOSE >= 2:
                            print("cgats_observer =", cgats_observer)
                        if (
                            cgats_instrument.decode("utf-8") != instrument
                            or cgats_measurement_mode != measurement_mode
                            or cgats_observer.decode("utf-8") != observer
                        ):
                            ti3 = None
                    else:
                        ti3 = None
                if DEBUG or VERBOSE >= 2:
                    print(f"last_{name}_ti3_path =", ti3)
                bmp = get_icon(16, "checkmark") if ti3 else get_icon(16, "empty")
                getattr(dlg, f"measure_{name}").SetBitmapLabel(bmp)
                getattr(dlg, f"measure_{name}").Refresh()
                getattr(dlg, f"measure_{name}")._bmp.SetToolTipString(ti3 or "")
                if isinstance(event, wx.Event):
                    set_ok_btn_state()

            dlg.measurement_mode_reference.Bind(wx.EVT_CHOICE, check_last_ccxx_ti3)
            hsizer.Add(
                dlg.measurement_mode_reference,
                flag=wx.RIGHT | wx.TOP | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL,
                border=8,
            )
            # Make measure button height match instrument choice height
            btn_h = dlg.reference_instrument.Size[1]
            if sys.platform == "win32" and sys.getwindowsversion() < (6, 2):
                # Windows 7 / Vista / XP
                btn_h += 2
            dlg.measure_reference = BitmapWithThemedButton(
                dlg,
                id_measure_reference,
                get_icon(16, "empty"),
                lang.getstr("measure"),
                size=(-1, btn_h),
            )
            if sys.platform == "win32":
                dlg.measure_reference.SetBackgroundColour(dlg.BackgroundColour)
            dlg.measure_reference.Bind(wx.EVT_BUTTON, dlg.OnClose)
            hsizer.Add(
                dlg.measure_reference,
                flag=wx.RIGHT | wx.TOP | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL,
                border=4,
            )
            dlg.measure_reference.Enable(
                bool(self.worker.displays and reference_instruments)
            )
            dlg.observer_reference_label = wx.StaticText(
                dlg, -1, lang.getstr("observer")
            )
            hsizer = wx.BoxSizer(wx.HORIZONTAL)
            boxsizer.Add(hsizer, flag=wx.BOTTOM, border=4)
            hsizer.Add(
                dlg.observer_reference_label,
                flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL,
                border=4,
            )
            dlg.observer_reference_ctrl = wx.Choice(
                dlg, -1, choices=list(self.observers_ab.values())
            )
            dlg.observer_reference_ctrl.Bind(wx.EVT_CHOICE, check_last_ccxx_ti3)
            hsizer.Add(
                dlg.observer_reference_ctrl,
                flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL,
                border=8,
            )
            dlg.observer_reference_ctrl.SetStringSelection(
                self.observers_ab[getcfg("colorimeter_correction.observer.reference")]
            )
            dlg.observer_reference_label.Show(bool(getcfg("show_advanced_options")))
            dlg.observer_reference_ctrl.Show(bool(getcfg("show_advanced_options")))

            # Reference TI3
            default_dir, default_file = get_verified_path("last_reference_ti3_path")
            dlg.reference_ti3 = FileBrowseBitmapButtonWithChoiceHistory(
                dlg,
                -1,
                dialogTitle=lang.getstr("measurement_file.choose.reference"),
                toolTip=lang.getstr("browse"),
                startDirectory=default_dir,
                fileMask=lang.getstr("filetype.ti3") + "|*.ti3;*.icm;*.icc",
            )
            if default_file:
                dlg.reference_ti3.SetPath(os.path.join(default_dir, default_file))
                wx.CallAfter(dlg.reference_ti3.setupControl)
            dlg.reference_ti3.changeCallback = check_last_ccxx_ti3
            dlg.reference_ti3.SetMaxFontSize(11)
            dlg.reference_ti3_droptarget = FileDrop(dlg)

            def reference_ti3_drop_handler(path: str) -> None:
                """Handle dropping a file onto the reference TI3 control.

                Args:
                    path (str): The path of the dropped file.
                """
                dlg.reference_ti3.SetPath(path)
                check_last_ccxx_ti3(dlg.reference_ti3.textControl)
                set_ok_btn_state()

            dlg.reference_ti3_droptarget.drophandlers = {
                ".icc": reference_ti3_drop_handler,
                ".icm": reference_ti3_drop_handler,
                ".ti3": reference_ti3_drop_handler,
            }
            dlg.reference_ti3.SetDropTarget(dlg.reference_ti3_droptarget)
            boxsizer.Add(
                dlg.reference_ti3,
                flag=wx.RIGHT | wx.BOTTOM | wx.LEFT | wx.EXPAND,
                border=4,
            )

            def reference_instrument_handler(event: wx.Event) -> None:
                """Handle the selection of the reference instrument.

                Args:
                    event (wx.Event): The event that triggered this handler.
                """
                mode, modes, dlg.modes_ab, modes_ba = self.get_measurement_modes(
                    dlg.reference_instrument.GetStringSelection(),
                    "spect",
                    "colorimeter_correction.measurement_mode.reference",
                )
                dlg.measurement_mode_reference.SetItems(modes["spect"])
                dlg.measurement_mode_reference.SetSelection(
                    min(modes_ba["spect"].get(mode, 1), len(modes["spect"]) - 1)
                )
                dlg.measurement_mode_reference.Enable(bool(modes["spect"]))
                boxsizer.Layout()
                if event:
                    check_last_ccxx_ti3(event)

            instrument = getcfg("colorimeter_correction.instrument.reference")
            if instrument in reference_instruments:
                dlg.reference_instrument.SetStringSelection(instrument)
            elif reference_instruments:
                dlg.reference_instrument.SetSelection(0)
            else:
                dlg.measurement_mode_reference.Disable()
            if reference_instruments:
                reference_instrument_handler(None)
            if len(reference_instruments) < 2:
                dlg.reference_instrument.Disable()
            else:
                dlg.reference_instrument.Bind(
                    wx.EVT_CHOICE, reference_instrument_handler
                )
            # Instrument
            boxsizer = wx.StaticBoxSizer(
                wx.StaticBox(dlg, -1, lang.getstr("instrument")), wx.VERTICAL
            )
            dlg.sizer3.Add(boxsizer, 1, flag=wx.TOP | wx.EXPAND, border=12)
            if sys.platform not in ("darwin", "win32"):
                boxsizer.Add((1, 8))
            hsizer = wx.BoxSizer(wx.HORIZONTAL)
            boxsizer.Add(hsizer, flag=wx.EXPAND)
            dlg.instrument = wx.Choice(dlg, -1, choices=colorimeters)
            hsizer.Add(
                dlg.instrument,
                1,
                flag=wx.LEFT | wx.TOP | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL,
                border=4,
            )
            hsizer.Add(
                wx.StaticText(dlg, -1, lang.getstr("measurement_mode")),
                flag=wx.LEFT | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL,
                border=8,
            )
            dlg.measurement_mode = wx.Choice(dlg, -1, choices=[])
            dlg.measurement_mode.Bind(wx.EVT_CHOICE, check_last_ccxx_ti3)
            hsizer.Add(
                dlg.measurement_mode,
                flag=wx.RIGHT | wx.TOP | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL,
                border=8,
            )
            dlg.measure_colorimeter = BitmapWithThemedButton(
                dlg,
                id_measure_colorimeter,
                get_icon(16, "empty"),
                lang.getstr("measure"),
                size=(-1, btn_h),
            )
            if sys.platform == "win32":
                dlg.measure_colorimeter.SetBackgroundColour(dlg.BackgroundColour)
            dlg.measure_colorimeter.Bind(wx.EVT_BUTTON, dlg.OnClose)
            hsizer.Add(
                dlg.measure_colorimeter,
                flag=wx.RIGHT | wx.TOP | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL,
                border=4,
            )
            dlg.measure_colorimeter.Enable(bool(self.worker.displays and colorimeters))
            dlg.observer_label = wx.StaticText(dlg, -1, lang.getstr("observer"))
            hsizer = wx.BoxSizer(wx.HORIZONTAL)
            boxsizer.Add(hsizer, flag=wx.BOTTOM, border=4)
            hsizer.Add(
                dlg.observer_label, flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL, border=4
            )
            dlg.observer_ctrl = wx.Choice(
                dlg, -1, choices=list(self.observers_ab.values())
            )
            dlg.observer_ctrl.Bind(wx.EVT_CHOICE, check_last_ccxx_ti3)
            hsizer.Add(
                dlg.observer_ctrl, flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL, border=8
            )
            dlg.observer_ctrl.SetStringSelection(
                self.observers_ab[getcfg("colorimeter_correction.observer")]
            )

            # Colorimeter TI3
            default_dir, default_file = get_verified_path("last_colorimeter_ti3_path")
            dlg.colorimeter_ti3 = FileBrowseBitmapButtonWithChoiceHistory(
                dlg,
                -1,
                dialogTitle=lang.getstr("measurement_file.choose.colorimeter"),
                toolTip=lang.getstr("browse"),
                startDirectory=default_dir,
                fileMask=lang.getstr("filetype.ti3") + "|*.ti3;*.icm;*.icc",
            )
            if default_file:
                dlg.colorimeter_ti3.SetPath(os.path.join(default_dir, default_file))
                wx.CallAfter(dlg.colorimeter_ti3.setupControl)
            dlg.colorimeter_ti3.changeCallback = check_last_ccxx_ti3
            dlg.colorimeter_ti3.SetMaxFontSize(11)
            dlg.colorimeter_ti3_droptarget = FileDrop(dlg)

            def colorimeter_ti3_drop_handler(path: str) -> None:
                """Handle dropping a file onto the colorimeter TI3 control.

                Args:
                    path (str): The path of the dropped file.
                """
                dlg.colorimeter_ti3.SetPath(path)
                check_last_ccxx_ti3(dlg.colorimeter_ti3.textControl)
                set_ok_btn_state()

            dlg.colorimeter_ti3_droptarget.drophandlers = {
                ".icc": colorimeter_ti3_drop_handler,
                ".icm": colorimeter_ti3_drop_handler,
                ".ti3": colorimeter_ti3_drop_handler,
            }
            dlg.colorimeter_ti3.SetDropTarget(dlg.colorimeter_ti3_droptarget)
            boxsizer.Add(
                dlg.colorimeter_ti3,
                flag=wx.RIGHT | wx.BOTTOM | wx.LEFT | wx.EXPAND,
                border=4,
            )

            def show_observer_ctrl() -> None:
                """Show or hide the observer control based on the instrument."""
                instrument_name = dlg.instrument.GetStringSelection()
                show = bool(
                    getcfg("show_advanced_options")
                    and self.worker.instrument_can_use_nondefault_observer(
                        instrument_name
                    )
                    and getcfg("colorimeter_correction.observer")
                    != DEFAULTS["colorimeter_correction.observer"]
                )
                dlg.observer_label.Show(show)
                dlg.observer_ctrl.Show(show)
                instrument_name = dlg.reference_instrument.GetStringSelection()
                show = bool(
                    dlg.correction_type_matrix.GetValue()
                    and getcfg("show_advanced_options")
                    and self.worker.instrument_can_use_nondefault_observer(
                        instrument_name
                    )
                )
                dlg.observer_reference_label.Show(show)
                dlg.observer_reference_ctrl.Show(show)

            def instrument_handler(event: wx.Event) -> None:
                """Handle the instrument selection.

                Args:
                    event (wx.Event): The event that triggered this handler.
                """
                dlg.Freeze()
                modes = self.get_ccxx_measurement_modes(
                    dlg.instrument.GetStringSelection()
                )
                dlg.measurement_mode.SetItems(list(modes.values()))
                dlg.measurement_mode.SetStringSelection(
                    modes.get(
                        getcfg("colorimeter_correction.measurement_mode"),
                        list(modes.values())[-1],
                    )
                )
                dlg.measurement_mode.Enable(bool(modes))
                show_observer_ctrl()
                boxsizer.Layout()
                if event:
                    check_last_ccxx_ti3(event)
                dlg.sizer0.SetSizeHints(dlg)
                dlg.sizer0.Layout()
                dlg.Refresh()
                dlg.Thaw()

            instrument = getcfg("colorimeter_correction.instrument")
            if instrument in colorimeters:
                dlg.instrument.SetStringSelection(instrument)
            elif colorimeters:
                dlg.instrument.SetSelection(0)
            else:
                dlg.measurement_mode.Disable()
            if colorimeters:
                instrument_handler(None)
            if len(colorimeters) < 2:
                dlg.instrument.Disable()
            else:
                dlg.instrument.Bind(wx.EVT_CHOICE, instrument_handler)

            # Bind event handlers
            def correction_type_handler(event: wx.Event) -> None:
                """Handle the correction type selection.

                Args:
                    event (wx.Event): The event that triggered this handler.
                """
                dlg.Freeze()
                for item in [*list(boxsizer.Children), boxsizer.StaticBox]:
                    if isinstance(item, (wx.SizerItem, wx.Window)):
                        item.Show(dlg.correction_type_matrix.GetValue())
                matrix = dlg.correction_type_matrix.GetValue()
                dlg.four_color_matrix.Enable(matrix)
                if not matrix:
                    dlg.four_color_matrix.SetValue(False)
                show_observer_ctrl()
                set_ok_btn_state()
                dlg.sizer0.SetSizeHints(dlg)
                dlg.sizer0.Layout()
                dlg.Refresh()
                dlg.Thaw()

            dlg.correction_type_matrix.Bind(wx.EVT_RADIOBUTTON, correction_type_handler)
            dlg.correction_type_spectral.Bind(
                wx.EVT_RADIOBUTTON, correction_type_handler
            )
            # Layout
            check_last_ccxx_ti3(dlg.measurement_mode_reference)
            check_last_ccxx_ti3(dlg.measurement_mode)
            correction_type_handler(None)
            result = dlg.ShowWindowModalBlocking()
            if result != wx.ID_CANCEL:
                observer = self.observers_ba.get(
                    dlg.observer_reference_ctrl.GetStringSelection()
                )
                setcfg("colorimeter_correction.observer.reference", observer)
            if result in (id_measure_reference, id_measure_colorimeter):
                setcfg(
                    "colorimeter_correction.instrument.reference",
                    dlg.reference_instrument.GetStringSelection(),
                )
                mode, modes, modes_ab, modes_ba = self.get_measurement_modes(
                    dlg.reference_instrument.GetStringSelection(),
                    "spect",
                    "colorimeter_correction.measurement_mode.reference",
                )
                mode = (
                    modes_ab.get("spect", {}).get(
                        dlg.measurement_mode_reference.GetSelection()
                    )
                    or "l"
                )
                setcfg(
                    "colorimeter_correction.measurement_mode.reference",
                    (strtr(mode, {"V": "", "H": ""}) if mode else None) or None,
                )
                setcfg(
                    "colorimeter_correction.measurement_mode.reference.adaptive",
                    1 if mode and "V" in mode else 0,
                )
                setcfg(
                    "colorimeter_correction.measurement_mode.reference.highres",
                    1 if mode and "H" in mode else 0,
                )
                setcfg(
                    "colorimeter_correction.measurement_mode.reference.projector",
                    1 if mode and "p" in mode else None,
                )
                observer = self.observers_ba.get(dlg.observer_ctrl.GetStringSelection())
                setcfg("colorimeter_correction.observer", observer)
                setcfg(
                    "colorimeter_correction.instrument",
                    dlg.instrument.GetStringSelection(),
                )
                modes = self.get_ccxx_measurement_modes(
                    dlg.instrument.GetStringSelection(), True
                )
                if dlg.measurement_mode.GetStringSelection() in modes:
                    setcfg(
                        "colorimeter_correction.measurement_mode",
                        modes[dlg.measurement_mode.GetStringSelection()],
                    )
            elif result == wx.ID_OK:
                paths = [getcfg("last_reference_ti3_path")]
                if dlg.correction_type_matrix.GetValue():
                    paths.append(getcfg("last_colorimeter_ti3_path"))
            setcfg(
                "ccmx.use_four_color_matrix_method",
                int(dlg.four_color_matrix.GetValue()),
            )
            if result != wx.ID_CANCEL:
                setcfg(
                    "colorimeter_correction.type",
                    {True: "matrix", False: "spectral"}[
                        dlg.correction_type_matrix.GetValue()
                    ],
                )
            dlg.Destroy()
        else:
            result = -1
        if result == wx.ID_CANCEL:
            return None
        if result in (id_measure_reference, id_measure_colorimeter):
            # Select CCXX testchart
            ccxx_testchart = get_ccxx_testchart()
            if not ccxx_testchart:
                show_result_dialog(
                    Error(lang.getstr("not_found", lang.getstr("ccxx.ti1"))), self
                )
                return None
            if not is_ccxx_testchart():
                # Backup testchart selection
                setcfg("testchart.file.backup", getcfg("testchart.file"))
            self.set_testchart(ccxx_testchart)
            # Backup instrument selection
            setcfg("comport.number.backup", getcfg("comport.number"))
            # Backup observer
            setcfg("observer.backup", getcfg("observer"))
            if result == id_measure_reference:
                # Switch to reference instrument
                setcfg(
                    "comport.number",
                    self.worker.instruments.index(
                        getcfg("colorimeter_correction.instrument.reference")
                    )
                    + 1,
                )
                # Set measurement mode
                setcfg(
                    "measurement_mode",
                    getcfg("colorimeter_correction.measurement_mode.reference"),
                )
                setcfg(
                    "measurement_mode.adaptive",
                    getcfg(
                        "colorimeter_correction.measurement_mode.reference.adaptive"
                    ),
                )
                setcfg(
                    "measurement_mode.highres",
                    getcfg("colorimeter_correction.measurement_mode.reference.highres"),
                )
                setcfg(
                    "measurement_mode.projector",
                    getcfg(
                        "colorimeter_correction.measurement_mode.reference.projector"
                    ),
                )
                # Set observer
                setcfg("observer", getcfg("colorimeter_correction.observer.reference"))
            else:
                # Switch to colorimeter
                setcfg(
                    "comport.number",
                    self.worker.instruments.index(
                        getcfg("colorimeter_correction.instrument")
                    )
                    + 1,
                )
                # Set measurement mode
                setcfg(
                    "measurement_mode",
                    getcfg("colorimeter_correction.measurement_mode"),
                )
                # Set observer
                setcfg("observer", getcfg("colorimeter_correction.observer"))
            self.measure_handler()
            return None
        try:
            ccxx_testchart = get_ccxx_testchart()
            if not ccxx_testchart:
                raise Error(lang.getstr("not_found", lang.getstr("ccxx.ti1")))
            ccxx = CGATS(ccxx_testchart)
        except (
            OSError,
            Error,
            CGATSInvalidError,
            CGATSInvalidOperationError,
            CGATSKeyError,
            CGATSTypeError,
            CGATSValueError,
        ) as exception:
            show_result_dialog(exception, self)
            return None
        cgats_list = []
        reference_ti3 = None
        colorimeter_ti3 = None
        spectral = False
        if getcfg("colorimeter_correction.type") == "matrix":
            ti3_range = (0, 1)
        else:
            ti3_range = (0,)
        for n in range(len(paths or ti3_range)):
            path = None
            if not paths:
                if reference_ti3:
                    default_dir, default_file = get_verified_path(
                        "last_colorimeter_ti3_path"
                    )
                    msg = lang.getstr("measurement_file.choose.colorimeter")
                else:
                    default_dir, default_file = get_verified_path(
                        "last_reference_ti3_path"
                    )
                    msg = lang.getstr("measurement_file.choose.reference")
                dlg = wx.FileDialog(
                    parent,
                    msg,
                    defaultDir=default_dir,
                    defaultFile=default_file,
                    wildcard=lang.getstr("filetype.ti3") + "|*.ti3;*.icm;*.icc",
                    style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
                )
                dlg.Center(wx.BOTH)
                if dlg.ShowModal() == wx.ID_OK:
                    path = dlg.GetPath()
                dlg.Destroy()
            else:
                path = paths[n]
            if path:
                try:
                    if os.path.splitext(path.lower())[1] in (".icm", ".icc"):
                        profile = ICCProfile(path)
                        meta = profile.tags.get("meta", {})
                        cgats = self.worker.ti1_lookup_to_ti3(
                            ccxx, profile, pcs="x", intent="a"
                        )[1]
                        cgats.add_keyword(
                            "DATA_SOURCE",
                            meta.get("DATA_source", {}).get("value", "").upper()
                            or "Unknown",
                        )
                        if cgats.DATA_SOURCE == "EDID":
                            instrument = "EDID"
                        else:
                            targ = profile.tags.get(
                                "CIED", profile.tags.get("targ", "")
                            )
                            instrument = None
                            if targ[0:4] == "CTI3":
                                targ = CGATS(targ)
                                instrument = targ.queryv1("TARGET_INSTRUMENT")
                            if not instrument:
                                instrument = meta.get("MEASUREMENT_device", {}).get(
                                    "value", "Unknown"
                                )
                        cgats.add_keyword("TARGET_INSTRUMENT", instrument)
                        spec_type = (
                            "YES"
                            if instruments.get(
                                get_canonical_instrument_name(cgats.TARGET_INSTRUMENT),
                                {},
                            ).get("spectral", False)
                            else "NO"
                        )
                        cgats.add_keyword("INSTRUMENT_TYPE_SPECTRAL", spec_type)
                        cgats.ARGYLL_COLPROF_ARGS = CGATS()
                        cgats.ARGYLL_COLPROF_ARGS.key = "ARGYLL_COLPROF_ARGS"
                        cgats.ARGYLL_COLPROF_ARGS.parent = cgats
                        cgats.ARGYLL_COLPROF_ARGS.root = cgats
                        cgats.ARGYLL_COLPROF_ARGS.type = "SECTION"
                        display = (
                            meta.get("EDID_model", meta.get("EDID_model_id", {}))
                            .get("value", "")
                            .encode("utf-7")
                        )
                        manufacturer = (
                            meta.get("EDID_manufacturer", {})
                            .get("value", "")
                            .encode("utf-7")
                        )
                        cgats.ARGYLL_COLPROF_ARGS.add_data(
                            f'-M "{display}" -A "{manufacturer}"'
                        )
                        cgats = CGATS(bytes(cgats))
                    else:
                        cgats = CGATS(path)
                    if not cgats.queryv1("DATA"):
                        raise CGATSError("Missing DATA")
                except Exception:
                    traceback.print_exc()
                    InfoDialog(
                        self,
                        title=lang.getstr("colorimeter_correction.create"),
                        msg=lang.getstr("error.measurement.file_invalid", path),
                        ok=lang.getstr("ok"),
                        bitmap=get_icon(32, "dialog-error"),
                    )
                    return None
                else:
                    cgats_list.append(cgats)
                    # Check if measurement contains spectral values
                    # Check if instrument type is spectral
                    if (
                        cgats.queryv1("SPECTRAL_BANDS")
                        or cgats.queryv1("DATA_SOURCE") == b"EDID"
                    ):
                        if reference_ti3:
                            # We already have a reference ti3
                            reference_ti3 = None
                            break
                        reference_ti3 = cgats
                        setcfg("last_reference_ti3_path", path)
                        if cgats.queryv1("SPECTRAL_BANDS"):
                            spectral = True
                            if (
                                event
                                and getcfg("colorimeter_correction.type") == "matrix"
                            ):
                                result = -1
                            else:
                                result = wx.ID_OK
                            if result == wx.ID_OK:
                                break
                            if result == wx.ID_CANCEL:
                                return None
                    elif cgats.queryv1("INSTRUMENT_TYPE_SPECTRAL") == b"YES":
                        if reference_ti3:
                            # We already have a reference ti3
                            reference_ti3 = None
                            break
                        reference_ti3 = cgats
                        setcfg("last_reference_ti3_path", path)
                    elif cgats.queryv1("INSTRUMENT_TYPE_SPECTRAL") == b"NO":
                        if colorimeter_ti3:
                            # We already have a colorimeter ti3
                            colorimeter_ti3 = None
                            break
                        colorimeter_ti3 = cgats
                        setcfg("last_colorimeter_ti3_path", path)
            else:
                # User canceled dialog
                return None
        # Check if atleast one file has been measured with a reference
        if not reference_ti3:
            InfoDialog(
                self,
                title=lang.getstr("colorimeter_correction.create"),
                msg=lang.getstr("error.measurement.one_reference"),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            return None
        if event:
            cfgname = "colorimeter_correction.measurement_mode"
        else:
            cfgname = "measurement_mode"
        if len(cgats_list) == 2:
            if not colorimeter_ti3:
                # If 2 files, check if atleast one file has NOT been measured
                # with a spectro (CCMX creation)
                InfoDialog(
                    self,
                    title=lang.getstr("colorimeter_correction.create"),
                    msg=lang.getstr("error.measurement.one_colorimeter"),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                return None
            # Use only the device combinations from CCXX testchart
            reference_new = CGATS(b"BEGIN_DATA\nEND_DATA")
            reference_new.DATA_FORMAT = reference_ti3.queryv1("DATA_FORMAT")
            colorimeter_new = CGATS(b"BEGIN_DATA\nEND_DATA")
            colorimeter_new.DATA_FORMAT = colorimeter_ti3.queryv1("DATA_FORMAT")
            data_reference = reference_ti3.queryv1("DATA")
            data_colorimeter = colorimeter_ti3.queryv1("DATA")
            required = ccxx.queryv(("RGB_R", "RGB_G", "RGB_B"))
            devicecombination2name = {
                "RGB_R=100 RGB_G=100 RGB_B=100": "white",
                "RGB_R=100 RGB_G=0 RGB_B=0": "red",
                "RGB_R=0 RGB_G=100 RGB_B=0": "green",
                "RGB_R=0 RGB_G=0 RGB_B=100": "blue",
            }
            for _i in required:
                values = required[_i]
                patch = {
                    "RGB_R": values[0],
                    "RGB_G": values[1],
                    "RGB_B": values[2],
                }
                devicecombination = " ".join(
                    ["=".join([key, f"{value:.0f}"]) for key, value in patch.items()]
                )
                name = devicecombination2name.get(devicecombination, devicecombination)
                item = data_reference.queryi1(patch)
                if item:
                    reference_new.DATA.add_data(item)
                else:
                    show_result_dialog(
                        lang.getstr(
                            "error.testchart.missing_fields",
                            (
                                os.path.basename(reference_ti3.filename),
                                lang.getstr(name),
                            ),
                        )
                    )
                    return None
                item = data_colorimeter.queryi1(patch)
                if item:
                    colorimeter_new.DATA.add_data(item)
                else:
                    show_result_dialog(
                        lang.getstr(
                            "error.testchart.missing_fields",
                            (
                                os.path.basename(colorimeter_ti3.filename),
                                lang.getstr(name),
                            ),
                        )
                    )
                    return None
            reference_ti3.queryi1("DATA").DATA = reference_new.DATA
            colorimeter_ti3.queryi1("DATA").DATA = colorimeter_new.DATA
            # If the reference comes from EDID, normalize luminance
            if reference_ti3.queryv1("DATA_SOURCE") == b"EDID":
                white = colorimeter_ti3.queryi1("DATA").queryi1(
                    {"RGB_R": 100, "RGB_G": 100, "RGB_B": 100}
                )
                scale = luminance / 100.0 if luminance else 1.0
                white = " ".join(
                    [
                        str(v)
                        for v in (
                            white["XYZ_X"] * scale,
                            white["XYZ_Y"] * scale,
                            white["XYZ_Z"] * scale,
                        )
                    ]
                )
                colorimeter_ti3.queryi1("DATA").LUMINANCE_XYZ_CDM2 = white
            # Add display base ID if missing
            self.worker.check_add_display_type_base_id(colorimeter_ti3, cfgname)
        elif not spectral:
            # If 1 file, check if it contains spectral values (CCSS creation)
            InfoDialog(
                self,
                title=lang.getstr("colorimeter_correction.create"),
                msg=lang.getstr("error.measurement.missing_spectral"),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            return None
        # Add display type
        for cgats in cgats_list:
            if not cgats.queryv1("DISPLAY_TYPE_REFRESH"):
                cgats[0].add_keyword(
                    "DISPLAY_TYPE_REFRESH",
                    {"c": b"YES", "l": b"NO"}.get(getcfg(cfgname), b"NO"),
                )
                print(
                    "Added DISPLAY_TYPE_REFRESH {!r}".format(
                        cgats[0].DISPLAY_TYPE_REFRESH.decode("utf-8")
                    )
                )
        options_dispcal, options_colprof = get_options_from_ti3(reference_ti3)
        display = None
        manufacturer = None
        manufacturer_display = None
        for option in options_colprof:
            if option.startswith("M"):
                display = option[1:].strip(' "')
            elif option.startswith("A"):
                manufacturer = option[1:].strip(' "')
        if manufacturer:
            quirk_manufacturer = colord.quirk_manufacturer(manufacturer)
        if (
            manufacturer
            and display
            and quirk_manufacturer.lower() not in display.lower()
        ):
            manufacturer_display = f"{quirk_manufacturer} {display}"
        elif display:
            manufacturer_display = display

        if isinstance(manufacturer_display, bytes):
            manufacturer_display = manufacturer_display.decode("utf-8")

        if len(cgats_list) == 2:
            instrument = colorimeter_ti3.queryv1("TARGET_INSTRUMENT")
            if instrument:
                instrument = get_canonical_instrument_name(instrument)
            if isinstance(instrument, bytes):
                instrument = instrument.decode("utf-8")
            observer = getcfg("colorimeter_correction.observer.reference")
            if observer == "1931_2":
                description = "{} & {}".format(  # noqa: UP032
                    instrument or self.worker.get_instrument_name(),
                    manufacturer_display or self.worker.get_display_name(True, True),
                )
            else:
                description = "{} ({} {}) & {}".format(
                    instrument or self.worker.get_instrument_name(),
                    lang.getstr(f"observer.{observer}"),
                    lang.getstr("observer"),
                    manufacturer_display or self.worker.get_display_name(True, True),
                )
        else:
            description = manufacturer_display or self.worker.get_display_name(
                True, True
            )
        if sys.platform == "darwin":
            # In case of internal screen, get 'nice' description
            model_id = display or util_mac.get_model_id()
            if model_id and re.match(
                r"iBook|iMac|MacBook|PowerBook", model_id, flags=re.I
            ):
                attrs = util_mac.get_machine_attributes(model_id) or {}
                description = description.replace(
                    model_id, attrs.get("marketingModel", model_id)
                )
        target_instrument = reference_ti3.queryv1("TARGET_INSTRUMENT")
        if target_instrument:
            target_instrument = get_canonical_instrument_name(target_instrument)
            if isinstance(target_instrument, bytes):
                target_instrument = target_instrument.decode("utf-8")
            description = f"{description} ({target_instrument})"
        args = []
        tech = {b"YES": "Unknown"}.get(
            reference_ti3.queryv1("DISPLAY_TYPE_REFRESH"), b"LCD"
        )
        technology_strings = self.worker.get_technology_strings()
        debug_print(
            "reference_ti3.queryv1('DISPLAY_TYPE_REFRESH'): "
            f"{reference_ti3.queryv1('DISPLAY_TYPE_REFRESH')}"
        )
        debug_print(f"tech: {tech}")
        debug_print(f"technology_string: {technology_strings}")
        if event:
            # Allow user to alter description, display and instrument
            dlg = ConfirmDialog(
                parent,
                title=lang.getstr("colorimeter_correction.create"),
                msg=lang.getstr("colorimeter_correction.create.details"),
                ok=lang.getstr("ok"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-information"),
            )
            boxsizer = wx.StaticBoxSizer(
                wx.StaticBox(dlg, -1, lang.getstr("description")), wx.VERTICAL
            )
            dlg.sizer3.Add(boxsizer, 1, flag=wx.TOP | wx.EXPAND, border=12)
            if sys.platform not in ("darwin", "win32"):
                boxsizer.Add((1, 8))
            dlg.description_txt_ctrl = wx.TextCtrl(dlg, -1, description, size=(400, -1))
            boxsizer.Add(
                dlg.description_txt_ctrl,
                1,
                flag=wx.ALL | wx.ALIGN_LEFT | wx.EXPAND,
                border=4,
            )
            boxsizer = wx.StaticBoxSizer(
                wx.StaticBox(dlg, -1, lang.getstr("display")), wx.VERTICAL
            )
            dlg.sizer3.Add(boxsizer, 1, flag=wx.TOP | wx.EXPAND, border=12)
            if sys.platform not in ("darwin", "win32"):
                boxsizer.Add((1, 8))
            if not display:
                # protects from an improperly formatted display name in the ref.ti3
                display = self.worker.get_display_name(False, True, False)
            dlg.display_txt_ctrl = wx.TextCtrl(dlg, -1, display, size=(400, -1))
            boxsizer.Add(
                dlg.display_txt_ctrl,
                1,
                flag=wx.ALL | wx.ALIGN_LEFT | wx.EXPAND,
                border=4,
            )
            use_manufacturer_txt_ctrl = not manufacturer
            if use_manufacturer_txt_ctrl:
                boxsizer = wx.StaticBoxSizer(
                    wx.StaticBox(dlg, -1, lang.getstr("display.manufacturer")),
                    wx.VERTICAL,
                )
                dlg.sizer3.Add(boxsizer, 1, flag=wx.TOP | wx.EXPAND, border=12)
                if sys.platform not in ("darwin", "win32"):
                    boxsizer.Add((1, 8))
                if not PNP_ID_CACHE:
                    # Populate pnpidcache
                    get_manufacturer_name("???")
                dlg.manufacturer_txt_ctrl = wx.Choice(
                    dlg,
                    -1,
                    choices=natsort(list(PNP_ID_CACHE.values())),
                    size=(400, -1),
                )
                manufacturer_selection = self.worker.get_display_edid().get(
                    "manufacturer", ""
                )
                if len(manufacturer_selection) < 1:
                    manufacturer_selection = "Unknown"
                dlg.manufacturer_txt_ctrl.SetStringSelection(manufacturer_selection)
                boxsizer.Add(
                    dlg.manufacturer_txt_ctrl,
                    1,
                    flag=wx.ALL | wx.ALIGN_LEFT | wx.EXPAND,
                    border=4,
                )
            # Display technology
            boxsizer = wx.StaticBoxSizer(
                wx.StaticBox(dlg, -1, lang.getstr("display.tech")), wx.VERTICAL
            )
            dlg.sizer3.Add(boxsizer, 1, flag=wx.TOP | wx.EXPAND, border=12)
            if sys.platform not in ("darwin", "win32"):
                boxsizer.Add((1, 8))
            loctech = {}
            techloc = {}
            for technology_string in list(technology_strings.values()):
                loc = lang.getstr(
                    f"display.tech.{technology_string}", default=technology_string
                )
                loctech[loc] = technology_string
                techloc[technology_string] = loc
            dlg.display_tech_ctrl = wx.Choice(dlg, -1, choices=list(loctech.keys()))
            dlg.display_tech_ctrl.SetStringSelection(techloc.get(tech, ""))
            boxsizer.Add(
                dlg.display_tech_ctrl, flag=wx.ALL | wx.ALIGN_LEFT | wx.EXPAND, border=4
            )
            btn = PlateButton(
                dlg, -1, lang.getstr("info.display_tech.show"), get_icon(16, "info")
            )
            set_platebutton_bitmaps(btn, "info")
            btn.Bind(wx.EVT_BUTTON, self.display_tech_info_show_handler)
            boxsizer.Add(btn, flag=wx.ALL | wx.ALIGN_LEFT, border=4)
            dlg.description_txt_ctrl.SetFocus()
            dlg.sizer0.SetSizeHints(dlg)
            dlg.sizer0.Layout()
            dlg.Center()
            result = dlg.ShowWindowModalBlocking()
            description = safe_str(dlg.description_txt_ctrl.GetValue().strip(), "UTF-8")
            display = dlg.display_txt_ctrl.GetValue()
            if (
                dlg.display_tech_ctrl.IsEnabled()
                and dlg.display_tech_ctrl.GetStringSelection()
            ):
                tech = loctech[dlg.display_tech_ctrl.GetStringSelection()]
            if use_manufacturer_txt_ctrl:
                manufacturer = dlg.manufacturer_txt_ctrl.GetStringSelection()
            dlg.Destroy()
            if result != wx.ID_OK:
                return None
        else:
            description += " AUTO"
        args.extend(["-E", description])
        if display:
            args.extend(["-I", safe_str(display.strip(), "UTF-8")])
        ccxxmake_version = get_argyll_version("ccxxmake")
        if reference_ti3 and (not colorimeter_ti3 or ccxxmake_version >= [1, 7]):
            if ccxxmake_version >= [1, 7]:
                args.extend(
                    [
                        "-t",
                        {v: k for k, v in technology_strings.items()}.get(tech, "u"),
                    ]
                )
            else:
                args.extend(["-T", safe_str(tech, "UTF-8")])
        # Prepare our files
        cwd = self.worker.create_tempdir()
        ti3_tmp_names = []
        if reference_ti3:
            reference_ti3.write(os.path.join(cwd, "reference.ti3"))
            ti3_tmp_names.append("reference.ti3")
        result = True
        if colorimeter_ti3:
            # Create CCMX
            colorimeter_ti3.write(os.path.join(cwd, "colorimeter.ti3"))
            ti3_tmp_names.append("colorimeter.ti3")
            name = "correction"
            ext = ".ccmx"
            # CCSS-capable instruments enable creating a CCMX that maps from
            # non-standard observer A used for the colorimeter measurements
            # to non-standard observer B used for the reference measurements.
            # To get correct readings (= matching reference observer B) when
            # using such a CCMX, observer A needs to be used, not observer B.
            observer = colorimeter_ti3.queryv1("OBSERVER")
            reference_observer = getcfg("colorimeter_correction.observer.reference")
            if spectral and reference_observer != reference_ti3.queryv1(
                "OBSERVER"
            ).decode("utf-8"):
                # We can override the observer if we have spectral data
                # Need to use spec2cie to convert spectral data to
                # CIE XYZ with given observer, because we later use the XYZ
                spec2cie = get_argyll_util("spec2cie")
                if not spec2cie:
                    show_result_dialog(
                        Error(lang.getstr("argyll.util.not_found", "spec2cie"))
                    )
                    self.worker.wrapup(False)
                    return None
                os.rename(
                    os.path.join(cwd, "reference.ti3"),
                    os.path.join(cwd, "reference_orig.ti3"),
                )
                result = self.worker.exec_cmd(
                    spec2cie,
                    [
                        "-o",
                        reference_observer,
                        os.path.join(cwd, "reference_orig.ti3"),
                        os.path.join(cwd, "reference.ti3"),
                    ],
                    capture_output=True,
                    skip_scripts=True,
                    silent=False,
                    working_dir=cwd,
                )
                if not isinstance(result, Exception) and result:
                    ref_ti3_fn_orig = reference_ti3.filename
                    reference_ti3 = CGATS(os.path.join(cwd, "reference.ti3"))
                    reference_ti3.filename = ref_ti3_fn_orig
                    # spec2cie doesn't update "LUMINANCE_XYZ_CDM2", and doesn't
                    # normalize measurement data to Y=100
                    XYZ_CDM2 = reference_ti3.queryv1("LUMINANCE_XYZ_CDM2")  # noqa: N806
                    white = reference_ti3.queryi1(
                        {"RGB_R": 100, "RGB_G": 100, "RGB_B": 100}
                    )
                    scale = white["XYZ_Y"] / 100.0
                    if XYZ_CDM2:
                        # Fix LUMINANCE_XYZ_CDM2
                        # Note that for oberservers other than 1931 2 degree,
                        # Y is not in cd/m2, but we try and keep the same
                        # relationship
                        XYZ_CDM2 = [float(v) for v in XYZ_CDM2.split()]  # noqa: N806
                        XYZ_CDM2 = [  # noqa: N806
                            f"{v * XYZ_CDM2[1] / 100.0:.6f}"
                            for v in list(
                                white.queryv1(("XYZ_X", "XYZ_Y", "XYZ_Z")).values()
                            )
                        ]
                        reference_ti3[0].LUMINANCE_XYZ_CDM2 = " ".join(XYZ_CDM2)
                    data_format = reference_ti3.queryv1("DATA_FORMAT")
                    # Remove L*a*b*. Do not use iter, as we change the
                    # dictionary in-place
                    for i, column in list(data_format.items()):
                        if column.startswith("LAB_"):
                            del data_format[i]
                    # Normalize to Y=100
                    data = reference_ti3.queryv1("DATA")
                    for _i in data:
                        sample = data[_i]
                        for column in data_format.values():
                            if column.startswith(("XYZ_", "SPEC_")):
                                sample[column] /= scale
                    reference_ti3.write()
                    # The -o observer argument for ccxxmake isn't really needed
                    # when we used spec2cie. Add it regardless for good measure
                    args.append("-o")
                    args.append(reference_observer)
            else:
                reference_observer = reference_ti3.queryv1("OBSERVER").decode("utf-8")
        else:
            # Create CCSS
            args.append("-S")
            name = "calibration"
            ext = ".ccss"
            observer = None
            reference_observer = None
        args.append("-f")
        args.append(",".join(ti3_tmp_names))
        args.append(name + ext)
        if not isinstance(result, Exception) and result:
            result = self.worker.create_ccxx(args, cwd)
        source = os.path.join(self.worker.tempdir, name + ext)
        if isinstance(result, Exception):
            show_result_dialog(result, self)
        elif result and os.path.isfile(source):
            if colorimeter_ti3:
                white_abs = []
                for _j, meas in enumerate((reference_ti3, colorimeter_ti3)):
                    # Get absolute whitepoint
                    _lum = meas.queryv1("LUMINANCE_XYZ_CDM2")
                    white = (
                        _lum.decode("utf-8") if _lum is not None else None
                    ) or meas.queryi1({"RGB_R": 100, "RGB_G": 100, "RGB_B": 100})
                    if isinstance(white, str):
                        white = [float(v) for v in white.split()]
                    elif isinstance(white, CGATS):
                        white = white["XYZ_X"], white["XYZ_Y"], white["XYZ_Z"]
                    else:
                        # This shouldn't happen
                        white = colormath.get_whitepoint("D65", scale=100)
                        print(
                            APPNAME + ": Warning - could not find white - "
                            "dE calculation will be inaccurate"
                        )
                    white_abs.append(white)
                if DEBUG or VERBOSE > 1:
                    print("ref white {:.6f} {:.6f} {:.6f}".format(*white_abs[0]))
                white_ref = [v / white_abs[0][1] for v in white_abs[0]]
                if getcfg("ccmx.use_four_color_matrix_method"):
                    print(f"{APPNAME}: Creating matrix using four-color method")
                    XYZ = []  # noqa: N806
                    for j, meas in enumerate((reference_ti3, colorimeter_ti3)):
                        for R, G, B in [  # noqa: N806
                            (100, 0, 0),
                            (0, 100, 0),
                            (0, 0, 100),
                            (100, 100, 100),
                        ]:
                            item = meas.queryi1("DATA").queryi1(
                                {"RGB_R": R, "RGB_G": G, "RGB_B": B}
                            )
                            X, Y, Z = item["XYZ_X"], item["XYZ_Y"], item["XYZ_Z"]  # noqa: N806
                            X, Y, Z = (v * white_abs[j][1] / 100.0 for v in (X, Y, Z))  # noqa: N806
                            XYZ.extend((X, Y, Z))
                    R = colormath.four_color_matrix(*XYZ)  # noqa: N806
                    print(f"{APPNAME}: Correction matrix is:")
                    ccmx = CGATS(source)
                    for i in range(3):
                        print("  {:.6f} {:.6f} {:.6f}".format(*R[i]))
                        for j, component in enumerate("XYZ"):
                            ccmx[0].DATA[i][f"XYZ_{component}"] = R[i][j]
                    ccmx.write()
            # Important: Do not use parsed CGATS, order of keywords may be
            # different than raw data so MD5 will be different
            try:
                with open(source, "rb") as cgatsfile:
                    cgats = universal_newlines(cgatsfile.read())
            except Exception as exception:
                traceback.print_exc()
                show_result_dialog(exception, self)
                self.worker.wrapup(False)
                return None
            if reference_ti3[0].get("TARGET_INSTRUMENT") and not re.search(
                rb'\nREFERENCE\s+".+?"\n', cgats
            ):
                # By default, CCSS files don't contain reference instrument
                cgats = re.sub(
                    rb'(\nDISPLAY\s+"[^"]*"\n)',
                    b'\nREFERENCE "%s"\\1'
                    % reference_ti3[0].get("TARGET_INSTRUMENT").replace(b"\\", b"\\\\"),
                    cgats,
                )
            if not re.search(rb'\nTECHNOLOGY\s+".+?"\n', cgats) and tech:
                # By default, CCMX files don't contain technology string
                cgats = re.sub(
                    rb'(\nDISPLAY\s+"[^"]*"\n)',
                    b'\nTECHNOLOGY "%s"\\1' % tech,
                    cgats,
                )
            manufacturer_id = None
            if manufacturer:
                if not PNP_ID_CACHE:
                    # Populate pnpidcache
                    get_manufacturer_name("???")
                manufacturers = dict([name, id_] for id_, name in PNP_ID_CACHE.items())
                manufacturer_id = manufacturers.get(manufacturer)
            debug_print(f"manufacturer_id: {manufacturer_id}")
            debug_print(f"manufacturer   : {manufacturer}")
            if manufacturer_id and not re.search(
                rb'\nMANUFACTURER_ID\s+".+?"\n', cgats
            ):
                # By default, CCMX/CCSS files don't contain manufacturer ID
                cgats = re.sub(
                    rb'(\nDISPLAY\s+"[^"]*"\n)',
                    b'\nMANUFACTURER_ID "%s"\\1'
                    % manufacturer_id.replace("\\", "\\\\").encode("utf-8"),
                    cgats,
                )
            if manufacturer and not re.search(rb'\nMANUFACTURER\s+".+?"\n', cgats):
                # By default, CCMX/CCSS files don't contain manufacturer
                cgats = re.sub(
                    rb'(\nDISPLAY\s+"[^"]*"\n)',
                    b'\nMANUFACTURER "%s"\\1'
                    % manufacturer.replace("\\", "\\\\").encode("utf-8"),
                    cgats,
                )
            if observer and not re.search(rb'\nOBSERVER\s+".+?"\n', cgats):
                # By default, CCMX/CCSS files don't contain observer
                cgats = re.sub(
                    rb'(\nDISPLAY\s+"[^"]*"\n)',
                    b'\nOBSERVER "%s"\\1' % observer.replace(b"\\", b"\\\\"),
                    cgats,
                )
            if reference_observer and not re.search(
                rb'\nREFERENCE_OBSERVER\s+".+?"\n', cgats
            ):
                # By default, CCMX/CCSS files don't contain observer
                cgats = re.sub(
                    rb'(\nDISPLAY\s+"[^"]*"\n)',
                    b'\nREFERENCE_OBSERVER "%s"\\1'
                    % reference_observer.encode("UTF-8").replace(b"\\", b"\\\\"),
                    cgats,
                )
            result = check_create_dir(config.get_argyll_data_dir())
            if isinstance(result, Exception):
                show_result_dialog(result, self)
                self.worker.wrapup(False)
                return None
            if colorimeter_ti3:
                # CCMX
                # Show reference vs corrected colorimeter values along with
                # delta E
                matrix = colormath.Matrix3x3()
                ccmx = CGATS(cgats)
                for i, sample in ccmx.queryv1("DATA").items():
                    matrix.append([])
                    for component in "XYZ":
                        matrix[i].append(sample[f"XYZ_{component}"])
                dlg = ConfirmDialog(
                    parent,
                    msg=lang.getstr("colorimeter_correction.create.success"),
                    ok=lang.getstr("save"),
                    cancel=lang.getstr("testchart.discard"),
                    bitmap=get_icon(32, "dialog-information"),
                )
                sizer = wx.BoxSizer(wx.HORIZONTAL)
                dlg.sizer3.Add(sizer, 1, flag=wx.TOP | wx.EXPAND, border=12)
                labels = (
                    "{} ({})".format(
                        get_canonical_instrument_name(
                            reference_ti3.queryv1("TARGET_INSTRUMENT")
                            or lang.getstr("instrument")
                        ).decode("utf-8"),
                        lang.getstr("reference"),
                    ),
                    "{} ({})".format(
                        get_canonical_instrument_name(
                            ccmx.queryv1("INSTRUMENT") or lang.getstr("instrument")
                        ).decode("utf-8"),
                        lang.getstr("corrected"),
                    ),
                )
                scale = getcfg("app.dpi") / config.get_default_dpi()
                scale = max(scale, 1)
                for i, label in enumerate(labels):
                    txt = wx.StaticText(
                        dlg,
                        -1,
                        label,
                        size=(80 * scale * (3 + i), -1),
                        style=wx.ALIGN_CENTER_HORIZONTAL,
                    )
                    font = txt.Font
                    font.SetWeight(wx.FONTWEIGHT_BOLD)
                    txt.Font = font
                    sizer.Add(txt, flag=wx.LEFT, border=40)
                if "gtk3" in wx.PlatformInfo:
                    style = wx.BORDER_SIMPLE
                else:
                    style = wx.BORDER_THEME
                grid = CustomGrid(
                    dlg,
                    -1,
                    size=(
                        640 * scale + wx.SystemSettings_GetMetric(wx.SYS_VSCROLL_X),
                        -1,
                    ),
                    style=style,
                )
                grid.Size = grid.Size[0], grid.GetDefaultRowSize() * 4
                dlg.sizer3.Add(grid, flag=wx.TOP | wx.ALIGN_LEFT, border=4)
                grid.DisableDragColSize()
                grid.DisableDragRowSize()
                grid.SetCellHighlightPenWidth(0)
                grid.SetCellHighlightROPenWidth(0)
                grid.SetColLabelSize(grid.GetDefaultRowSize())
                grid.SetDefaultCellAlignment(wx.ALIGN_CENTER, wx.ALIGN_CENTER)
                grid.SetMargins(0, 0)
                grid.SetRowLabelAlignment(wx.ALIGN_RIGHT, wx.ALIGN_CENTER)
                grid.SetRowLabelSize(40)
                grid.SetScrollRate(0, 5)
                grid.draw_horizontal_grid_lines = False
                grid.draw_vertical_grid_lines = False
                grid.EnableEditing(False)
                grid.EnableGridLines(False)
                grid.CreateGrid(0, 9)
                for i, label in enumerate(
                    ["x", "y", "Y", "", "", "x", "y", "Y", "ΔE*00"]
                ):
                    # Rectangular (width = height)
                    size = grid.GetDefaultRowSize() if i in (3, 4) else 80 * scale
                    grid.SetColSize(i, int(size))
                    grid.SetColLabelValue(i, label)
                grid.BeginBatch()
                ref_data = reference_ti3.queryv1("DATA")
                tgt_data = colorimeter_ti3.queryv1("DATA")
                deltaE_94 = []  # noqa: N806
                deltaE_00 = []  # noqa: N806
                print("")
                print(
                    "      Reference xyY         |"
                    "      Corrected xyY         |"
                    "   DE94   |   DE00   "
                )
                print("-" * 80)
                for i in ref_data:
                    ref = ref_data[i]
                    tgt = tgt_data[i]
                    grid.AppendRows(1)
                    row = grid.GetNumberRows() - 1
                    grid.SetRowLabelValue(row, f"{ref.SAMPLE_ID:.0f}")
                    XYZ = []  # noqa: N806
                    XYZabs = []  # noqa: N806
                    xyYabs = []  # noqa: N806
                    for j, sample in enumerate((ref, tgt)):
                        # Get samples
                        XYZ.append([])
                        for component in "XYZ":
                            XYZ[j].append(sample[f"XYZ_{component}"])
                        # Scale to absolute brightness
                        XYZabs.append(list(XYZ[j]))
                        for k, value in enumerate(XYZabs[j]):
                            XYZabs[j][k] = value * white_abs[j][1] / 100.0
                        if j == 1:
                            # Apply matrix to colorimeter measurements
                            XYZabs[j] = matrix * XYZabs[j]
                        xyYabs.append(colormath.XYZ2xyY(*XYZabs[j]))
                        # Set cell values
                        for k, value in enumerate(xyYabs[j]):
                            grid.SetCellValue(row, j * 5 + k, f"{value:.4f}")
                        # Show sRGB approximation of measured patch
                        X, Y, Z = [  # noqa: N806
                            v / max(white_abs[0][1], (matrix * white_abs[1])[1])
                            for v in XYZabs[j]
                        ]
                        # Adapt from reference white to D65
                        X, Y, Z = colormath.adapt(X, Y, Z, white_ref, "D65")  # noqa: N806
                        # Convert XYZ to sRGB
                        RGB = [round(v) for v in colormath.XYZ2RGB(X, Y, Z, scale=255)]  # noqa: N806
                        grid.SetCellBackgroundColour(row, 3 + j, wx.Colour(*RGB))
                    if DEBUG or VERBOSE > 1:
                        print(
                            "ref {:.6f} {:.6f} {:.6f}, ".format(*XYZabs[0]),
                            "col {:.6f} {:.6f} {:.6f}".format(*XYZabs[1]),
                        )
                    Lab_ref = colormath.XYZ2Lab(*XYZabs[0] + [white_abs[0]])  # noqa: N806
                    Lab_tgt = colormath.XYZ2Lab(*XYZabs[1] + [white_abs[0]])  # noqa: N806
                    if DEBUG or VERBOSE > 1:
                        print(
                            "ref Lab {:.6f} {:.6f} {:.6f}, ".format(*Lab_ref),
                            "col Lab {:.6f} {:.6f} {:.6f}".format(*Lab_tgt),
                        )
                    # For comparison to Argyll DE94 values
                    deltaE = colormath.delta(*Lab_ref + Lab_tgt + ("94",))["E"]  # noqa: N806
                    deltaE_94.append(deltaE)
                    deltaE = colormath.delta(*Lab_ref + Lab_tgt + ("00",))["E"]  # noqa: N806
                    deltaE_00.append(deltaE)
                    print(
                        (
                            " {:.6f} {:.6f} {:8.4f} | "
                            "{:.6f} {:.6f} {:8.4f} | {:.6f} | {:.6f} "
                        ).format(
                            *(
                                tuple(xyYabs[0])
                                + tuple(xyYabs[1])
                                + (deltaE_94[-1], deltaE_00[-1])
                            )
                        )
                    )
                    grid.SetCellValue(row, 8, f"{deltaE:.4f}")
                print("")
                print(
                    f"{APPNAME}: Fit error is max {max(deltaE_94):.6f}, "
                    f"avg {sum(deltaE_94) / len(deltaE_94):.6f} DE94"
                )
                print(
                    f"{APPNAME}: Fit error is max {max(deltaE_00):.6f}, "
                    f"avg {sum(deltaE_00) / len(deltaE_00):.6f} DE00"
                )
                grid.DefaultCellBackgroundColour = grid.LabelBackgroundColour
                grid.EndBatch()
                dlg.sizer0.SetSizeHints(dlg)
                dlg.sizer0.Layout()
                result = dlg.ShowWindowModalBlocking() if event else wx.ID_OK
                dlg.Destroy()
                if result != wx.ID_OK:
                    self.worker.wrapup(False)
                    return None
                # Add dE fit error to CGATS as meta
                for label, fit_error in (
                    ("MAX_DE94", max(deltaE_94)),
                    ("AVG_DE94", sum(deltaE_94) / len(deltaE_94)),
                    ("MAX_DE00", max(deltaE_00)),
                    ("AVG_DE00", sum(deltaE_00) / len(deltaE_00)),
                ):
                    cgats = re.sub(
                        rb'(\nREFERENCE\s+"[^"]*"\n)',
                        (f'\\1FIT_{label} "{fit_error:.6f}"\n').encode("utf-8"),  # noqa: UP012
                        cgats,
                    )
            metadata = []
            # Add measurement file names and checksum to CCXX
            for label, meas in (
                ("REFERENCE", reference_ti3),
                ("TARGET", colorimeter_ti3),
            ):
                if meas and meas.filename:
                    metadata.append(
                        '{}_FILENAME "{}"'.format(
                            label, safe_str(meas.filename, "UTF-8")
                        )
                    )
                    metadata.append(
                        '{}_HASH "md5:{}"'.format(  # noqa: UP032
                            label,
                            md5(bytes(meas).strip()).hexdigest(),  # noqa: S324
                        )
                    )
            if DEBUG or TEST:
                # Add original measurement data to CGATS as meta
                ccmx_data_format = [
                    "RGB_R",
                    "RGB_G",
                    "RGB_B",
                    "XYZ_X",
                    "XYZ_Y",
                    "XYZ_Z",
                ]
                for label, meas in (
                    ("REFERENCE", reference_ti3),
                    ("TARGET", colorimeter_ti3),
                ):
                    XYZ_CDM2 = meas.queryv1("LUMINANCE_XYZ_CDM2")  # noqa: N806
                    if XYZ_CDM2:
                        metadata.append(
                            '{}_LUMINANCE_XYZ_CDM2 "{}"'.format(
                                label, XYZ_CDM2.decode("utf-8")
                            )
                        )
                    if not colorimeter_ti3:
                        break
                    metadata.append(
                        '{}_DATA_FORMAT "{}"'.format(label, " ".join(ccmx_data_format))
                    )
                    data_format = meas.queryv1("DATA_FORMAT")
                    data = meas.queryv1("DATA")
                    for i in data:
                        sample = data[i]
                        RGB_XYZ = [str(sample[column]) for column in ccmx_data_format]  # noqa: N806
                        metadata.append(
                            '{}_DATA_{:.0f} "{}"'.format(
                                label, i + 1, " ".join(RGB_XYZ)
                            )
                        )
                        # Line length limit for CGATS keywords 1024 chars, add
                        # spectral data as individual keywords
                        for column in data_format.values():
                            column = column.decode("utf-8")
                            if column not in ccmx_data_format and column != "SAMPLE_ID":
                                metadata.append(
                                    f"{label}_DATA_{i + 1:.0f}_{column} "
                                    f'"{sample[column]}"'
                                )
            if colorimeter_ti3 and getcfg("ccmx.use_four_color_matrix_method"):
                cgats = re.sub(
                    rb'(\nORIGINATOR\s+)"Argyll[^"]+"',
                    (rf'\1"{APPNAME} {VERSION_STRING}"').encode("utf-8"),  # noqa: UP012
                    cgats,
                )
                metadata.append('FIT_METHOD "xy"')
            else:
                metadata.append('FIT_METHOD "ΔE*94"')
            if metadata:
                debug_print(f"medatadata: {metadata}")
                cgats = re.sub(
                    rb'(\nREFERENCE\s+"[^"]*"\n)',
                    (
                        "\\1{}\n".format("\n".join(metadata).replace("\\", "\\\\"))
                    ).encode("utf-8"),
                    cgats,
                )
            if event:
                if colorimeter_correction_check_overwrite(
                    self, cgats, bool(colorimeter_ti3)
                ):
                    self.upload_colorimeter_correction(cgats)
            else:
                path = get_cgats_path(cgats)
                with open(path, "wb") as cgatsfile:
                    cgatsfile.write(cgats)
                setcfg("colorimeter_correction_matrix_file", ":" + path)
        elif result is not None:
            InfoDialog(
                self,
                title=lang.getstr("colorimeter_correction.create"),
                msg=lang.getstr("colorimeter_correction.create.failure")
                + "\n"
                + "".join(self.worker.errors),
                ok=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-error"),
                log=False,
            )
        self.worker.wrapup(False)
        return True

    def upload_colorimeter_correction(
        self, cgats: None | str | bytes | list | Path | io.IOBase | ICCProfileTag
    ) -> None:
        """Prompt user to upload colorimeter correction to the online database.

        Args:
            cgats (None | str | bytes | list | Path | io.IOBase | ICCProfileTag): The
                CGATS data to upload.
        """
        dlg = ConfirmDialog(
            self,
            msg=lang.getstr("colorimeter_correction.upload.confirm"),
            ok=lang.getstr("ok"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, "dialog-information"),
        )
        dlg.info = PlateButton(
            dlg.buttonpanel,
            -1,
            lang.getstr("colorimeter_correction.info"),
            get_icon(16, "info"),
        )
        set_platebutton_bitmaps(dlg.info, "info")

        def show_ccxx_info(event: wx.Event) -> None:
            """Show information about the colorimeter correction.

            Args:
                event (wx.Event): The event that triggered this handler.
            """
            self.colorimeter_correction_info_handler(event, cgats)

        dlg.info.Bind(wx.EVT_BUTTON, show_ccxx_info)
        dlg.sizer2.Insert(
            0, dlg.info, flag=wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=12
        )
        dlg.sizer2.Insert(0, (32 + 7, 1))
        result = dlg.ShowWindowModalBlocking()
        dlg.Destroy()
        if result != wx.ID_OK:
            return
        if isinstance(cgats, str):
            # upload_colorimeter_correction_handler() passes a str (decoded
            # from the file), but the regex below requires bytes; normalize
            # so it doesn't raise TypeError.
            cgats = cgats.encode("utf-8")
        ccxx = CGATS(cgats)
        # Remove platform-specific/potentially sensitive information
        cgats = re.sub(rb'\n(?:REFERENCE|TARGET)_FILENAME\s+"[^"]+"\n', b"\n", cgats)
        params = {"cgats": cgats}
        # Also upload reference and target CGATS (if available)
        for label in ("REFERENCE", "TARGET"):
            filename = (ccxx.queryv1(f"{label}_FILENAME") or b"").decode("utf-8")
            algo_hash = ((ccxx.queryv1(f"{label}_HASH") or b"").decode("utf-8")).split(
                ":", 1
            )
            if filename and os.path.isfile(filename) and algo_hash[0] in globals():
                meas = bytes(CGATS(filename)).strip()
                # Check hash
                if globals()[algo_hash[0]](meas).hexdigest() == algo_hash[-1]:
                    params[label.lower() + "_cgats"] = meas
        if DEBUG or TEST:
            print(list(params.keys()))
        # Upload correction
        self.worker.interactive = False
        self.worker.start(
            lambda result: result,
            upload_colorimeter_correction,
            wargs=(self, params),
            progress_msg=lang.getstr("colorimeter_correction.upload"),
            stop_timers=False,
            cancelable=False,
            show_remaining_time=False,
            fancy=False,
        )

    def upload_colorimeter_correction_handler(self, event: wx.Event) -> None:
        """Let user choose a ccss/ccmx file to upload.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        path = None
        default_dir, default_file = get_verified_path("last_filedialog_path")
        dlg = wx.FileDialog(
            self,
            lang.getstr("colorimeter_correction_matrix_file.choose"),
            defaultDir=default_dir,
            defaultFile=default_file,
            wildcard=lang.getstr("filetype.ccmx") + "|*.ccmx;*.ccss",
            style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
        )
        dlg.Center(wx.BOTH)
        if dlg.ShowModal() == wx.ID_OK:
            path = dlg.GetPath()
        dlg.Destroy()
        if not path:
            return
        setcfg("last_filedialog_path", path)
        # Important: Do not use parsed CGATS, order of keywords may be
        # different than raw data so MD5 will be different
        with open(path, "rb") as cgatsfile:
            cgats = cgatsfile.read().decode()
        originator = re.search(r'\nORIGINATOR\s+"Argyll', cgats)
        if not originator:
            originator = re.search(r'\nORIGINATOR\s+"' + APPNAME, cgats)
        if not originator:
            InfoDialog(
                self,
                msg=lang.getstr("colorimeter_correction.upload.deny"),
                ok=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-error"),
            )
        else:
            self.upload_colorimeter_correction(cgats)

    def comport_ctrl_handler(
        self, event: None | wx.Event = None, force: bool = False
    ) -> None:
        """Handle the COM port selection control.

        Updates the configuration based on the selected COM port
        and enables/disables menu items related to colorimeter calibration
        and colorimeter correction.

        Args:
            event (None | wx.Event, optional): The event that triggered this
                handler.
            force (bool, optional): If True, forces the update of the
                colorimeter correction matrix control items.
        """
        if DEBUG and event:
            print(
                "[D] comport_ctrl_handler called for ID "
                f"{event.GetId()} {getevtobjname(event, self)} event type "
                f"{event.GetEventType()} {getevttype(event)}"
            )
        if self.comport_ctrl.GetSelection() > -1:
            setcfg("comport.number", self.comport_ctrl.GetSelection() + 1)
        self.menuitem_calibrate_instrument.Enable(
            bool(self.worker.get_instrument_features().get("sensor_cal"))
        )
        self.update_measurement_modes()
        enable_ccxx = (
            self.worker.instrument_can_use_ccxx(False)
            and not is_ccxx_testchart()
            and getcfg("measurement_mode") != "auto"
        )
        self.menuitem_choose_colorimeter_correction.Enable(enable_ccxx)
        self.menuitem_colorimeter_correction_web.Enable(enable_ccxx)
        self.update_colorimeter_correction_matrix_ctrl()
        self.update_colorimeter_correction_matrix_ctrl_items(force)

    def import_colorimeter_corrections_handler(
        self,
        event: wx.Event,
        paths: None | list[str] = None,
        callafter: None | Callable = None,
        callafter_args: tuple = (),
    ) -> None | bool:
        """Import colorimeter corrections from other profiling software.

        Currently supported: iColor Display (native import to CCMX),
        i1 Profiler (import to CCSS via Argyll CMS >= 1.3.4)
        Spyder4/5 (import to spyd4cal.bin via Argyll CMS >= 1.3.6)

        Args:
            event (wx.Event): The event that triggered this handler.
            paths (None | list, optional): List of paths to files to import. If
                None, the user will be prompted to select files.
            callafter (callable, optional): Function to call after the import
                is done.
            callafter_args (tuple, optional): Arguments to pass to the
                callafter function.

        Returns:
            bool: True if the import was successful, False otherwise.
        """
        msg = " ".join(
            [
                lang.getstr("oem.import.auto"),
                lang.getstr("oem.import.auto.download_selection"),
            ]
        )
        if sys.platform == "win32":
            msg = " ".join([lang.getstr("oem.import.auto_windows"), msg])
        result = None
        i1d3 = None
        i1d3ccss = None
        spyd4 = None
        spyd4en = None
        icd = None
        oeminst = get_argyll_util("oeminst")
        importers = {}
        if not oeminst:
            i1d3ccss = get_argyll_util("i1d3ccss")
            spyd4en = get_argyll_util("spyd4en")
        dlg = ConfirmDialog(
            self,
            title=lang.getstr("colorimeter_correction.import"),
            msg=msg,
            ok=lang.getstr("auto"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, "dialog-information"),
            alt=lang.getstr("file.select"),
        )
        dlg.sizer3.Add((1, 8))

        def check_importers(event: wx.Event) -> None:
            """Check if at least one importer is selected.

            Args:
                event (wx.Event): The event that triggered this check.
            """
            result = False
            for name in ("i1d3", "icd", "spyd4"):
                if hasattr(dlg, name) and getattr(dlg, name).IsChecked():
                    result = True
                    break
            dlg.ok.Enable(result)

        for name, desc, instruments_, importer in [
            (
                "i1d3",
                "i1 Profiler",
                ("i1 DisplayPro, ColorMunki Display", "Spyder4", "Spyder5"),
                i1d3ccss or oeminst,
            ),
            (
                "icd",
                "iColor Display",
                ("DTP94", "i1 Display 2", "Spyder2", "Spyder3"),
                True,
            ),
            ("spyd4", "Spyder4/5", ("Spyder4", "Spyder5"), spyd4en or oeminst),
        ]:
            if not importer:
                continue
            for instrument in instruments_:
                if instrument not in desc:
                    desc += " ({})".format(", ".join(instruments_))
                    break
            setattr(dlg, name, wx.CheckBox(dlg, -1, desc))
            for instrument in instruments_:
                check = self.worker.spyder4_cal_exists() if name == "spyd4" else False
                if instrument in self.worker.instruments and not check:
                    getattr(dlg, name).SetValue(True)
                    break
            dlg.sizer3.Add(getattr(dlg, name), flag=wx.TOP | wx.ALIGN_LEFT, border=8)
            getattr(dlg, name).Bind(wx.EVT_CHECKBOX, check_importers)
        dlg.install_user = wx.RadioButton(
            dlg, -1, lang.getstr("install_user"), style=wx.RB_GROUP
        )
        dlg.install_user.SetValue(True)
        dlg.sizer3.Add(dlg.install_user, flag=wx.TOP | wx.ALIGN_LEFT, border=16)
        dlg.install_systemwide = wx.RadioButton(
            dlg, -1, lang.getstr("install_local_system")
        )
        dlg.install_user.Bind(wx.EVT_RADIOBUTTON, install_scope_handler)
        dlg.install_systemwide.Bind(wx.EVT_RADIOBUTTON, install_scope_handler)
        install_scope_handler(dlg=dlg)
        check_importers(None)
        dlg.sizer3.Add(dlg.install_systemwide, flag=wx.TOP | wx.ALIGN_LEFT, border=4)
        dlg.sizer0.SetSizeHints(dlg)
        dlg.sizer0.Layout()
        if event:
            choice = dlg.ShowModal()
        elif paths:
            choice = wx.ID_ANY
        else:
            choice = wx.ID_OK
        importer_configurations = [
            ("i1d3", i1d3ccss or oeminst),
            ("spyd4", spyd4en or oeminst),
            ("icd", True),
        ]
        importers = {
            name: importer
            for name, importer in importer_configurations
            if importer and getattr(dlg, name).GetValue()
        }
        asroot = dlg.install_systemwide.GetValue()
        dlg.Destroy()
        if choice == wx.ID_CANCEL:
            return None
        if choice != wx.ID_OK and not paths:
            dlg = wx.FileDialog(
                self,
                lang.getstr("colorimeter_correction.import.choose"),
                wildcard=lang.getstr("filetype.any") + "|*",
                style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST | wx.FD_MULTIPLE,
            )
            dlg.Center(wx.BOTH)
            choice2 = dlg.ShowModal()
            paths = dlg.GetPaths()
            dlg.Destroy()
            if choice2 != wx.ID_OK:
                return None
        elif not paths:
            paths = []
        if asroot:
            result = self.worker.authenticate(
                oeminst or i1d3ccss or spyd4en,
                lang.getstr("colorimeter_correction.import"),
                self,
            )
            if result not in (True, None):
                if isinstance(result, Exception):
                    show_result_dialog(result, self)
                return None
        self.worker.interactive = False
        self.worker.start(
            self.import_colorimeter_corrections_consumer,
            self.import_colorimeter_corrections_producer,
            cargs=(callafter, callafter_args),
            wargs=(
                result,
                i1d3,
                i1d3ccss,
                spyd4,
                spyd4en,
                icd,
                oeminst,
                paths,
                choice == wx.ID_OK,
                asroot,
                importers,
            ),
            progress_msg=lang.getstr("colorimeter_correction.import"),
            fancy=False,
        )
        return (event and None) or True  # This always returns True

    def import_colorimeter_correction(
        self,
        result: bool | Exception,
        i1d3: bool,
        i1d3ccss: bool,
        spyd4: bool,
        spyd4en: bool,
        icd: bool,
        oeminst: bool,
        path: str | list,
        asroot: bool,
    ) -> tuple[bool | Exception, bool, bool, bool]:
        """Import colorimeter correction(s) from path.

        Args:
            result (bool | Exception): Result of the import operation.
            i1d3 (bool): Whether to import i1D3 corrections.
            i1d3ccss (bool): Whether to import i1D3 corrections in CCSS format.
            spyd4 (bool): Whether to import Spyder4 corrections.
            spyd4en (bool): Whether to import Spyder4 corrections in
                spyd4cal.bin format.
            icd (bool): Whether to import iColor Display corrections.
            oeminst (bool): Whether to use OEM installer.
            path (str | list): Path(s) to the correction files.
            asroot (bool): Whether to install as root (system-wide).

        Returns:
            tuple: Result of the import operation, and flags indicating whether
                i1D3, Spyder4, and iColor Display corrections were imported.
        """
        debug_print("import_colorimeter_correction <-")
        debug_print("   result:", result)
        debug_print("   i1d3:", i1d3)
        debug_print("   i1d3ccss:", i1d3ccss)
        debug_print("   spyd4:", spyd4)
        debug_print("   spyd4en:", spyd4en)
        debug_print("   icd:", icd)
        debug_print("   oeminst:", oeminst)
        debug_print("   path(s):", path)
        debug_print("   asroot:", asroot)
        kind = None
        if isinstance(path, list):
            kind = "xrite"
        elif path and os.path.exists(path):
            filename, ext = os.path.splitext(path)
            kind = "unknown"
            if ext.lower() == ".txt":
                kind = "icd"
                result = True
            else:
                icolordisplay = "icolordisplay" in os.path.basename(path).lower()
                if ext.lower() == ".dmg":
                    if icolordisplay:
                        kind = "icd"
                        result = self.worker.exec_cmd(
                            which("hdiutil"),
                            ["attach", path],
                            capture_output=True,
                            skip_scripts=True,
                        )
                        if result and not isinstance(result, Exception):
                            for _path in safe_glob(
                                os.path.join(
                                    os.path.sep,
                                    "Volumes",
                                    "iColorDisplay*",
                                    "iColorDisplay*.app",
                                    "Contents",
                                    "Resources",
                                    "DeviceCorrections.txt",
                                )
                            ):
                                break
                            else:
                                result = Error(
                                    lang.getstr("file.missing", "DeviceCorrections.txt")
                                )
                elif i1d3ccss and ext.lower() == ".edr":
                    kind = "xrite"
                elif ext.lower() in (".cab", ".exe"):
                    if icolordisplay:
                        kind = "icd"
                        sevenzip = get_program_file("7z", "7-zip")
                        if sevenzip:
                            if not getcfg("dry_run"):
                                # Extract from NSIS installer
                                temp = self.worker.create_tempdir()
                                if isinstance(temp, Exception):
                                    result = temp
                                else:
                                    result = self.worker.exec_cmd(
                                        sevenzip,
                                        ["e", "-y", path, "DeviceCorrections.txt"],
                                        capture_output=True,
                                        skip_scripts=True,
                                        working_dir=temp,
                                    )
                                    if result and not isinstance(result, Exception):
                                        path = os.path.join(
                                            temp, "DeviceCorrections.txt"
                                        )
                                    else:
                                        self.worker.wrapup(False)
                        else:
                            result = Error(lang.getstr("file.missing", "7z" + EXE_EXT))
                    elif i1d3ccss and (
                        "colormunki" in os.path.basename(path).lower()
                        or "i1profiler" in os.path.basename(path).lower()
                        or os.path.basename(path).lower() == "i1d3"
                    ):
                        # Assume X-Rite installer
                        kind = "xrite"
                    elif spyd4en and (
                        "spyder4" in os.path.basename(path).lower()
                        or os.path.basename(path).lower() == "spyd4"
                    ):
                        # Assume Spyder4/5
                        kind = "spyder4"
        if kind:
            if kind == "icd":
                if (
                    not getcfg("dry_run")
                    and result
                    and not isinstance(result, Exception)
                ):
                    # Assume iColorDisplay DeviceCorrections.txt
                    ccmx_dir = config.get_argyll_data_dir()
                    if not os.path.exists(ccmx_dir):
                        result = check_create_dir(ccmx_dir)
                        if isinstance(result, Exception):
                            return result, i1d3, spyd4, icd
                    print(lang.getstr("colorimeter_correction.import"))
                    print(path)
                    try:
                        imported, skipped = ccmx.convert_devicecorrections_to_ccmx(
                            path, ccmx_dir
                        )
                        if imported == 0:
                            raise Info
                    except ValueError as exception:
                        result = Error(
                            lang.getstr("file.invalid") + "\n" + str(exception)
                        )
                    except Info:
                        result = False
                    except Exception as exception:
                        result = exception
                    else:
                        result = icd = True
                        if skipped > 0:
                            result = Warn(
                                lang.getstr(
                                    "colorimeter_correction.import.partial_warning",
                                    ("iColor Display", skipped, imported + skipped),
                                )
                            )
                    self.worker.wrapup(False)
            elif kind == "xrite":
                # Import .edr
                if asroot and sys.platform == "win32":
                    ccss = self.get_argyll_data_files("l", "*.ccss", True)
                args = path if isinstance(path, list) else [path]
                result = i1d3 = self.worker.import_edr(args, asroot=asroot)
                if asroot and sys.platform == "win32":
                    # Hacky but the only way to know if we were successful
                    result = i1d3 = (
                        self.get_argyll_data_files("l", "*.ccss", True) != ccss
                    )
            elif kind == "spyder4":
                # Import spyd4cal.bin
                result = spyd4 = self.worker.import_spyd4cal([path], asroot=asroot)
                if asroot and sys.platform == "win32":
                    result = spyd4 = self.get_argyll_data_files("l", "spyd4cal.bin")
            elif oeminst and not icolordisplay:
                if asroot and sys.platform == "win32":
                    ccss = self.get_argyll_data_files("l", "*.ccss", True)
                result = self.worker.import_colorimeter_corrections(
                    oeminst, [path], asroot
                )
                if ".ccss" in "".join(self.worker.output) or (
                    asroot
                    and sys.platform == "win32"
                    and self.get_argyll_data_files("l", "*.ccss", True) != ccss
                ):
                    i1d3 = result
                if "spyd4cal.bin" in "".join(self.worker.output) or (
                    asroot
                    and sys.platform == "win32"
                    and self.get_argyll_data_files("l", "spyd4cal.bin")
                ):
                    spyd4 = result
            else:
                result = Error(lang.getstr("error.file_type_unsupported") + "\n" + path)

        debug_print("import_colorimeter_correction ->")
        debug_print("   result:", result)
        debug_print("   i1d3:", i1d3)
        debug_print("   i1d3ccss:", i1d3ccss)
        debug_print("   spyd4:", spyd4)
        debug_print("   spyd4en:", spyd4en)
        debug_print("   icd:", icd)
        debug_print("   oeminst:", oeminst)
        debug_print("   path(s):", path)
        debug_print("   asroot:", asroot)
        return result, i1d3, spyd4, icd

    def import_colorimeter_corrections_producer(
        self,
        result: str,
        i1d3: bool,
        i1d3ccss: bool,
        spyd4: bool,
        spyd4en: bool,
        icd: bool,
        oeminst: str,
        paths: list,
        auto: bool,
        asroot: bool,
        importers: dict,
    ) -> tuple[bool | Exception, bool, bool, bool]:
        """Import colorimetercorrections from paths.

        Args:
            result (str): Initial result message.
            i1d3 (bool): Whether i1D3 corrections were imported.
            i1d3ccss (bool): Whether i1D3 CCSS corrections were imported.
            spyd4 (bool): Whether Spyder4/5 corrections were imported.
            spyd4en (bool): Whether Spyder4/5 EN corrections were imported.
            icd (bool): Whether iColorDisplay corrections were imported.
            oeminst (str): Path to the OEMINST utility.
            paths (list): List of paths to import from.
            auto (bool): Whether this is an automatic import.
            asroot (bool): Whether to install corrections system-wide.
            importers (dict): Dictionary of importers to use.

        Returns:
            tuple[bool | Exception, bool, bool, bool]: A tuple containing the
                result of the import operation which is either a bool or an
                Exception, and flags indicating whether i1D3, Spyder4, and
                iColor Display corrections were imported.
        """
        if auto and not paths:
            paths = []
            if importers.get("icd"):
                # Look for iColorDisplay
                if sys.platform == "win32":
                    icdfn = safe_glob(
                        os.path.join(
                            getenvu("PROGRAMFILES", ""),
                            "Quato",
                            "iColorDisplay",
                            "DeviceCorrections.txt",
                        )
                    )
                elif sys.platform == "darwin":
                    icdfn = safe_glob(
                        os.path.join(
                            os.path.sep,
                            "Applications",
                            "iColorDisplay*.app",
                            "DeviceCorrections.txt",
                        )
                    )
                    if not icdfn:
                        icdfn = safe_glob(
                            os.path.join(
                                os.path.sep,
                                "Volumes",
                                "iColorDisplay*",
                                "iColorDisplay*.app",
                                "DeviceCorrections.txt",
                            )
                        )
                else:
                    icdfn = None
                if icdfn:
                    paths.extend(icdfn)
            if importers.get("i1d3") and (oeminst or i1d3ccss) and not i1d3:
                # Look for *.edr files
                if sys.platform == "win32":
                    i1d3fn = safe_glob(
                        os.path.join(
                            getenvu("PROGRAMFILES", ""),
                            "X-Rite",
                            "Devices",
                            "i1d3",
                            "Calibrations",
                            "*.edr",
                        )
                    )
                elif sys.platform == "darwin":
                    i1d3fn = safe_glob(
                        os.path.join(
                            os.path.sep,
                            "Library",
                            "Application Support",
                            "X-Rite",
                            "Devices",
                            "i1d3xrdevice",
                            "Contents",
                            "Resources",
                            "Calibrations",
                            "*.edr",
                        )
                    )
                    if not i1d3fn:
                        i1d3fn = safe_glob(
                            os.path.join(
                                os.path.sep, "Volumes", "i1Profiler", "*Setup.exe"
                            )
                        )
                    if not i1d3fn:
                        i1d3fn = safe_glob(
                            os.path.join(
                                os.path.sep,
                                "Volumes",
                                "ColorMunki Display",
                                "*Setup.exe",
                            )
                        )
                else:
                    i1d3fn = []
                if len(i1d3fn) > 1:
                    # Multiple EDR files
                    paths.append(i1d3fn)
                else:
                    paths.extend(i1d3fn)
            if importers.get("spyd4") and (oeminst or spyd4en) and not spyd4:
                # Look for dccmtr.dll
                if sys.platform == "win32":
                    spydfn = safe_glob(
                        os.path.join(
                            getenvu("PROGRAMFILES", ""),
                            "Datacolor",
                            "Spyder5*",
                            "dccmtr.dll",
                        )
                    )
                    if not spydfn:
                        spydfn = safe_glob(
                            os.path.join(
                                getenvu("PROGRAMFILES", ""),
                                "Datacolor",
                                "Spyder4*",
                                "dccmtr.dll",
                            )
                        )
                elif sys.platform == "darwin":
                    # Look for setup.exe on CD-ROM
                    spydfn = safe_glob(
                        os.path.join(
                            os.path.sep, "Volumes", "Datacolor", "Data", "Setup.exe"
                        )
                    )
                    if not spydfn:
                        spydfn = safe_glob(
                            os.path.join(
                                os.path.sep,
                                "Volumes",
                                "Datacolor_ISO",
                                "Data",
                                "Setup.exe",
                            )
                        )
                else:
                    spydfn = None
                if spydfn:
                    paths.extend(spydfn)
        for path in paths:
            (result, i1d3, spyd4, icd) = self.import_colorimeter_correction(
                result, i1d3, i1d3ccss, spyd4, spyd4en, icd, oeminst, path, asroot
            )
        paths = []
        for name in importers:
            _importer = importers[name]
            imported = locals().get(name, False)
            if (imported and name != "i1d3") or not auto:
                continue
            # Automatic download
            if name == "icd" and sys.platform == "darwin":
                name += ".dmg"
            self.worker.recent.clear()
            self.worker.lastmsg.clear()
            # We always (re-)download the i1D3 package because it may contain
            # additional corrections not present in i1Profiler
            result = self.worker.download(
                f"https://{DOMAIN}/{name}", force=name == "i1d3"
            )
            if isinstance(result, Exception):
                break
            if result:
                if os.path.basename(result).lower() == "i1d3.zip":
                    # Extract contained CCSS files
                    result = self.worker.extract_archive(result)
                    if isinstance(result, Exception):
                        break
                    result = [path for path in result if not os.path.isdir(path)]
                paths.append(result)
            else:
                # Cancelled
                result = None
                break
        if not isinstance(result, Exception) and result:
            for path in paths:
                (result, i1d3, spyd4, icd) = self.import_colorimeter_correction(
                    result, i1d3, i1d3ccss, spyd4, spyd4en, icd, oeminst, path, asroot
                )
        return result, i1d3, spyd4, icd

    def import_colorimeter_corrections_consumer(
        self,
        results: tuple,
        callafter: None | Callable = None,
        callafter_args: tuple = (),
    ) -> None:
        """Consumer for importing colorimeter corrections.

        Args:
            results (tuple): A tuple containing the results of the import
                operation, which may include the result of the import,
                i1d3, spyd4, and icd.
            callafter (None | Callable, optional): A callable to be called after the
                import operation is complete.
            callafter_args (tuple, optional): Arguments to be passed to the
                callable specified in callafter.
        """
        print("results:", results)
        result, i1d3, spyd4, icd = results
        if isinstance(result, Exception):
            show_result_dialog(result, self)
        imported = []
        failures = []
        mapping = {
            "i1 Profiler/ColorMunki Display": i1d3,
            "Spyder4/5": spyd4,
            "iColor Display": icd,
        }
        for name in mapping:
            subresult = mapping[name]
            if subresult and not isinstance(subresult, Exception):
                imported.append(name)
            elif subresult is not None:
                failures.append(name)
        if imported:
            self.update_measurement_modes()
            self.update_colorimeter_correction_matrix_ctrl_items(True)
            InfoDialog(
                self,
                msg=lang.getstr(
                    "colorimeter_correction.import.success", "\n".join(imported)
                ),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-information"),
            )
        if failures or (not imported and result is not None):
            error = "".join(self.worker.errors) or lang.getstr(
                "colorimeter_correction.import.failure"
            ) + "\n\n" + "\n".join(failures)
            show_result_dialog(UnloggedError(error), self)
        if callafter:
            wx.CallAfter(callafter, *callafter_args)

    def import_session_archive(self, path: str) -> None:
        """Import compressed session archive.

        Args:
            path (str): The path to the session archive file.
        """
        filename, ext = os.path.splitext(path)
        basename = os.path.basename(filename)  # Without extension
        if not self.check_overwrite(filename=basename):
            return
        self.worker.start(
            self.import_session_archive_consumer,
            self.import_session_archive_producer,
            cargs=(basename,),
            wargs=(path, basename, ext),
            progress_msg=lang.getstr("archive.import"),
            fancy=False,
        )

    def import_session_archive_producer(
        self,
        path: str,
        basename: str,
        ext: str,
    ) -> str | Exception:
        """Producer for importing session archive.

        Args:
            path (str): The path to the session archive file.
            basename (str): The base name of the session archive without
                extension, used for saving the profile.
            ext (str): The file extension of the session archive.

        Returns:
            str | Exception: The path to the extracted session archive or an
                exception if an error occurred.
        """
        temp = self.worker.create_tempdir()
        if isinstance(temp, Exception):
            return temp
        if ext.lower() == ".7z":
            if not (sevenzip := get_program_file("7z", "7-zip")):
                return Error(lang.getstr("file.missing", f"7z{EXE_EXT}"))
            # Extract from 7z archive (flat hierarchy, not using dirnames)
            result = self.worker.exec_cmd(
                sevenzip,
                ["e", "-y", path],
                capture_output=True,
                log_output=False,
                skip_scripts=True,
                working_dir=temp,
            )
            if not result or isinstance(result, Exception):
                return result
            # Check if a session archive
            is_session_archive = False
            for ext_ in (".icc", ".icm", ".cal"):
                if os.path.isfile(os.path.join(temp, f"{basename}{ext_}")):
                    is_session_archive = True
                    break
            if not is_session_archive:
                # Doesn't seem to be a session archive
                return Error(
                    lang.getstr("error.not_a_session_archive", os.path.basename(path))
                )
            if os.path.isdir(os.path.join(temp, basename)):
                # Remove empty directory
                shutil.rmtree(os.path.join(temp, basename))

        else:
            if path.lower().endswith(".tgz") or path.lower().endswith(".tar.gz"):
                # Gzipped TAR archive
                archive = TarFileProper.open(path, "r", encoding="UTF-8")
                getinfo = archive.getmember
                getnames = archive.getnames
            else:
                # ZIP
                archive = zipfile.ZipFile(path, "r")
                getinfo = archive.getinfo
                getnames = archive.namelist
            try:
                with archive:
                    # Check if a session archive
                    info = None
                    for ext_ in (".icc", ".icm", ".cal"):
                        for name in (
                            f"{basename}/{basename}{ext_}",
                            f"{basename}{ext_}",
                        ):
                            if isinstance(archive, zipfile.ZipFile):
                                # If the ZIP file was created with Unicode
                                # names stored in the file, 'name' will already
                                # be Unicode. Otherwise, it'll either be 7-bit
                                # ASCII or (legacy) cp437 encoding
                                names = (name, safe_str(name, "cp437"))
                            else:
                                # Gzipped TAR archive, assume UTF-8
                                names = (safe_str(name, "UTF-8"),)
                            for name in names:
                                try:
                                    info = getinfo(name)
                                except KeyError:
                                    continue
                                break
                            if info:
                                break
                        if info:
                            break
                    if not info:
                        # Doesn't seem to be a session archive
                        return Error(
                            lang.getstr(
                                "error.not_a_session_archive", os.path.basename(path)
                            )
                        )
                    # Extract from archive (flat hierarchy, not using dirnames)
                    for name in getnames():
                        if not isinstance(archive, zipfile.ZipFile):
                            # Gzipped TAR
                            archive.extract(name, temp, False)
                            continue
                        # If the ZIP file was created with Unicode names stored
                        # in the file, 'name' will already be Unicode.
                        # Otherwise, it'll either be 7-bit ASCII or (legacy)
                        # cp437 encoding
                        outname = str(name)
                        with open(
                            os.path.join(temp, os.path.basename(outname)), "wb"
                        ) as outfile:
                            outfile.write(archive.read(name))
            except Exception as exception:
                print(traceback.format_exc())
                return exception
        return os.path.join(getcfg("profile.save_path"), basename, basename + ext)

    def import_session_archive_consumer(
        self, result: str | Exception, basename: str
    ) -> None:
        """Consumer for importing session archive.

        Args:
            result (str | Exception): The result of the import operation,
                which can be a file path or an exception.
            basename (str): The base name of the session archive without
                extension, used for saving the profile.
        """
        if result and not isinstance(result, Exception):
            # Copy to storage folder
            self.worker.wrapup(
                dst_path=os.path.join(
                    getcfg("profile.save_path"), basename, basename + ".ext"
                )
            )
            # Load settings from profile
            self.load_cal_handler(None, result)
        else:
            show_result_dialog(result)
            self.worker.wrapup(False)

    def display_ctrl_handler(
        self,
        event: wx.Event,
        load_lut: bool = True,
        update_ccmx_items: bool = True,
    ) -> None:
        """Handle display control events.

        Args:
            event (wx.Event): The event triggered by the control.
            load_lut (bool, optional): Whether to load the LUT after changing
                the display.
            update_ccmx_items (bool, optional): Whether to update colorimeter
                correction matrix items.
        """
        debug_print(
            "[D] display_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        display_no = self.display_ctrl.GetSelection()
        profile = None
        if display_no > -1:
            setcfg("display.number", display_no + 1)
            if load_lut:
                profile = get_display_profile(display_no)
                if not getcfg("calibration.file", False):
                    # Current
                    self.profile_info_btn.Enable(bool(profile))
        if self.display_lut_link_ctrl.IsShown():
            self.display_lut_link_ctrl_handler(
                CustomEvent(wx.EVT_BUTTON.evtType[0], self.display_lut_link_ctrl),
                bool(int(getcfg("display_lut.link"))),
            )
        if load_lut:
            debug_print(
                "[D] display_ctrl_handler -> lut_viewer_load_lut",
                profile.getDescription() if profile else None,
            )
            self.lut_viewer_load_lut(profile=profile)
            debug_print("[D] display_ctrl_handler -> lut_viewer_load_lut END")
        self.update_use_video_lut()
        # Enable 3D LUT tab for virtual displays & eeColor
        enable_3dlut_tab = (
            config.is_virtual_display() or config.get_display_name() == "SII REPEATER"
        )
        setcfg_cond(
            enable_3dlut_tab, "3dlut.tab.enable", 1, True, not getcfg("3dlut.create")
        )
        self.update_drift_compensation_ctrls()
        self.show_display_delay_ctrls()
        self.show_ffp_ctrls()
        self.show_output_levels_ctrls()
        self.update_output_levels_ctrl()
        # Check if display is calibratable at all. Unset calibration update
        # checkbox if this is not the case.
        if config.is_uncalibratable_display():
            setcfg("calibration.update", False)
            self.calibration_update_cb.SetValue(False)
        if self.IsShownOnScreen():
            self.update_menus()
        if (
            update_ccmx_items
            and getcfg("colorimeter_correction_matrix_file").split(":")[0] == "AUTO"
        ):
            self.update_colorimeter_correction_matrix_ctrl_items()
        else:
            self.update_estimated_measurement_times()
        self.update_main_controls()
        if getattr(self, "reportframe", None):
            self.reportframe.update_controls()
        if getattr(self, "lut3dframe", None):
            self.lut3dframe.update_controls()
        # if (event and not isinstance(event, CustomEvent) and
        # not getcfg("calibration.file", False)):
        # # Set measurement report dest profile to current
        # setcfg("measurement_report.output_profile",
        # get_current_profile_path())
        if isinstance(event, CustomEvent):
            return
        if config.get_display_name().startswith("Chromecast "):
            # Show a warning re Chromecast limitation
            show_result_dialog(
                UnloggedWarning(lang.getstr("chromecast_limitations_warning")),
                parent=self,
            )
        if (
            config.get_display_name() == "Untethered"
            and getcfg("testchart.file") == "auto"
        ):
            # Untethered does not support auto-optimization
            self.set_testchart()

    def update_output_levels_ctrl(self) -> None:
        """Update output levels controls based on configuration."""
        if getcfg("patterngenerator.detect_video_levels"):
            self.output_levels_auto.SetValue(True)
        else:
            use_video_levels = bool(getcfg("patterngenerator.use_video_levels"))
            self.output_levels_full_range.SetValue(not use_video_levels)
            self.output_levels_limited_range.SetValue(use_video_levels)

    def display_delay_handler(self, event: wx.Event) -> None:
        """Handle display delay control events.

        Args:
            event (wx.Event): The event triggered by the control.
        """
        mapping = {
            self.override_min_display_update_delay_ms.GetId(): (
                "measure.override_min_display_update_delay_ms"
            ),
            self.min_display_update_delay_ms.GetId(): (
                "measure.min_display_update_delay_ms"
            ),
            self.override_display_settle_time_mult.GetId(): (
                "measure.override_display_settle_time_mult"
            ),
            self.display_settle_time_mult.GetId(): "measure.display_settle_time_mult",
        }
        pref = mapping.get(event.GetId())
        if pref:
            ctrl = self.FindWindowById(event.GetId())
            value = ctrl.GetValue()
            if ctrl.Name.startswith("override_"):
                self.update_display_delay_ctrl(ctrl.Name[9:], value)
                value = int(value)
            setcfg(pref, value)
        self.update_estimated_measurement_times()

    def update_display_delay_ctrl(self, name: str, enable: bool) -> None:
        """Update display delay control state and value.

        Args:
            name (str): The name of the control to update.
            enable (bool): Whether to enable or disable the control.
        """
        spinctrl = getattr(self, name)
        spinctrl.Enable(enable)
        if name == "min_display_update_delay_ms":
            self.min_display_update_delay_ms_label.Enable(enable)
        spinvalue = getcfg(f"measure.{name}")
        if not enable:
            # Restore previous environment variable value
            backup = os.getenv(f"ARGYLL_{name.upper()}_BACKUP")
            current = os.getenv(f"ARGYLL_{name.upper()}")
            if backup or current:
                valuetype = type(DEFAULTS[f"measure.{name}"])
                with contextlib.suppress(TypeError, ValueError):
                    spinvalue = valuetype(backup or current)
        spinctrl.SetValue(spinvalue)

    def display_lut_ctrl_handler(self, event: wx.Event) -> None:
        """Handle display LUT selection changes.

        Args:
            event (wx.Event): The event triggered by the control.
        """
        debug_print(
            "[D] display_lut_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        try:
            i = self.displays.index(self.display_lut_ctrl.GetStringSelection())
        except ValueError:
            i = min(0, self.display_ctrl.GetSelection())
        setcfg("display_lut.number", i + 1)

    def display_lut_link_ctrl_handler(
        self, event: wx.Event, link: None | bool = None
    ) -> None:
        """Handle the display LUT link control event.

        Args:
            event (wx.Event): The event triggered by the control.
            link (bool, optional): The current link state. If None, it will
                be determined from the configuration.
        """
        debug_print(
            "[D] display_lut_link_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        bitmap_link = get_icon(16, "stock_lock")
        bitmap_unlink = get_icon(16, "stock_lock-open")
        if link is None:
            link = not bool(int(getcfg("display_lut.link")))
        link = not len(self.worker.displays) or (
            link
            and self.worker.lut_access[
                max(min(len(self.worker.displays), getcfg("display.number")), 0) - 1
            ]
        )
        lut_no = -1
        if link:
            self.display_lut_link_ctrl.SetBitmapLabel(bitmap_link)
            with contextlib.suppress(ValueError):
                lut_no = self.display_lut_ctrl.Items.index(
                    self.display_ctrl.GetStringSelection()
                )
        else:
            self.display_lut_link_ctrl.SetBitmapLabel(bitmap_unlink)
        set_bitmap_labels(self.display_lut_link_ctrl)
        if lut_no < 0:
            try:
                lut_no = self.display_lut_ctrl.Items.index(
                    self.display_ctrl.Items[getcfg("display_lut.number") - 1]
                )
            except (IndexError, ValueError):
                lut_no = min(0, self.display_ctrl.GetSelection())
        self.display_lut_ctrl.SetSelection(lut_no)
        self.display_lut_ctrl.Enable(not link and self.display_lut_ctrl.GetCount() > 0)
        setcfg("display_lut.link", int(link))
        try:
            i = self.displays.index(self.display_lut_ctrl.Items[lut_no])
        except (IndexError, ValueError):
            i = min(0, self.display_ctrl.GetSelection())
        setcfg("display_lut.number", i + 1)

    def display_tech_info_show_handler(self, event: wx.Event) -> None:
        """Show display technology information tooltip window.

        Args:
            event (wx.Event): The event triggered by the control.
        """
        if hasattr(self, "display_tech_info_tooltip_window"):
            # Hmm. Somehow initial scroll position isn't at (0, 0)
            wx.CallAfter(self.display_tech_info_tooltip_window.panel.Scroll, 0, 0)
            self.display_tech_info_tooltip_window.Show()
            self.display_tech_info_tooltip_window.Raise()
            return

        # Create tooltip window with display technology information
        id_str = "info.display_tech"
        lcode = lang.getcode()
        if lcode in ("ko", "zh_cn", "zh_hk") and lang.LDICT.get(lcode, {}).get(id_str):
            wrap = 66
        else:
            wrap = 112
        self.display_tech_info_tooltip_window = TooltipWindow(
            self,
            msg=lang.getstr(id_str),
            cols=1,
            title=lang.getstr("display.tech"),
            bitmap=get_icon(32, "dialog-information"),
            wrap=wrap,
            use_header=False,
            show=False,
            scrolled=True,
        )
        w = self.display_tech_info_tooltip_window
        w.sizer0.Add((0, 2))
        # link1 = HyperLinkCtrl(w.panel, -1,
        # label=lang.getstr(
        #     "info.display_tech.linklabel.displayspecifications.com"
        # ),
        # URL="https://www.displayspecifications.com/")
        # link1.BackgroundColour = w.panel.BackgroundColour
        link1 = PlateButton(
            w.panel,
            -1,
            lang.getstr("info.display_tech.linklabel.displayspecifications.com"),
            get_icon(16, "web"),
        )
        link1.SetMaxFontSize(11)
        set_platebutton_bitmaps(link1, "web")
        if sys.platform == "darwin":
            # Prevent initial highlited state
            link1.Unbind(wx.EVT_SET_FOCUS)
        link1.Bind(
            wx.EVT_BUTTON,
            lambda e: webbrowser_open("https://www.displayspecifications.com/"),
        )
        w.sizer0.Add(link1, flag=wx.LEFT, border=12 + 32 + 7)
        w.sizer0.Add((0, 9))
        # link2 = HyperLinkCtrl(w.panel, -1,
        # label=lang.getstr("info.display_tech.linklabel.everymac.com"),
        # URL="https://everymac.com/")
        # link2.BackgroundColour = w.panel.BackgroundColour
        link2 = PlateButton(
            w.panel,
            -1,
            lang.getstr("info.display_tech.linklabel.everymac.com"),
            get_icon(16, "web"),
        )
        link2.SetMaxFontSize(11)
        set_platebutton_bitmaps(link2, "web")
        if sys.platform == "darwin":
            # Prevent initial highlited state
            link2.Unbind(wx.EVT_SET_FOCUS)
        link2.Bind(wx.EVT_BUTTON, lambda e: webbrowser_open("https://everymac.com/"))
        w.sizer0.Add(link2, flag=wx.LEFT, border=12 + 32 + 7)
        w.sizer0.Add((0, 12))
        w.sizer0.SetSizeHints(w)
        sw = wx.SystemSettings_GetMetric(wx.SYS_VSCROLL_X)
        w.Size = w.MinSize = w.MinSize[0] + sw, w.MinSize[1]
        w.sizer0.Layout()
        # Hmm. Somehow initial scroll position isn't at (0, 0)
        wx.CallAfter(self.display_tech_info_tooltip_window.panel.Scroll, 0, 0)
        self.display_tech_info_tooltip_window.Show()
        self.display_tech_info_tooltip_window.Raise()

    def measurement_mode_ctrl_handler(self, event: wx.Event = None) -> None:
        """Handle changes to the measurement mode control.

        Args:
            event (wx.Event, optional): The event triggered by the control.
        """
        if event is not None:
            debug_print(
                "[D] measurement_mode_ctrl_handler called for ID "
                f"{event.GetId()} {getevtobjname(event, self)} event type "
                f"{event.GetEventType()} {getevttype(event)}"
            )
        v = self.get_measurement_mode()
        if v and "p" in v and self.worker.argyll_version < [1, 1, 0]:
            self.measurement_mode_ctrl.SetSelection(
                self.measurement_modes_ba[self.get_instrument_type()].get(
                    DEFAULTS["measurement_mode"], 1
                )
            )
            v = None
            InfoDialog(
                self,
                msg=lang.getstr("projector_mode_unavailable"),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-information"),
            )
        if (v and "V" in v and self.worker.argyll_version < [1, 1, 0]) or (
            self.worker.argyll_version[0:3] == [1, 1, 0]
            and (
                "Beta" in self.worker.argyll_version_string
                or "RC1" in self.worker.argyll_version_string
                or "RC2" in self.worker.argyll_version_string
            )
        ):
            # adaptive emissive mode was added in RC3
            self.measurement_mode_ctrl.SetSelection(
                self.measurement_modes_ba[self.get_instrument_type()].get(
                    DEFAULTS["measurement_mode"], 1
                )
            )
            v = None
            InfoDialog(
                self,
                msg=lang.getstr("adaptive_mode_unavailable"),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-information"),
            )
        cal_changed = (
            v != getcfg("measurement_mode")
            and getcfg("calibration.file", False) not in self.presets[1:]
        )
        setcfg(
            "measurement_mode", (strtr(v, {"V": "", "H": ""}) if v else None) or None
        )
        instrument_features = self.worker.get_instrument_features()
        if instrument_features.get("adaptive_mode"):
            setcfg("measurement_mode.adaptive", 1 if v and "V" in v else 0)
        if instrument_features.get("highres_mode"):
            setcfg("measurement_mode.highres", 1 if v and "H" in v else 0)
        if (
            v
            and self.worker.get_instrument_name() in ("ColorHug", "ColorHug2")
            and "p" in v
        ):
            # ColorHug projector mode is just a correction matrix
            # Avoid setting ColorMunki projector mode
            v = v.replace("p", "")
        # ColorMunki projector mode is an actual special sensor dial position
        setcfg("measurement_mode.projector", 1 if v and "p" in v else None)
        self.update_colorimeter_correction_matrix_ctrl()
        self.update_colorimeter_correction_matrix_ctrl_items(
            update_measurement_mode=False
        )
        if (
            v
            and self.get_trc()
            and ("c" not in v or "p" in v)
            and float(self.get_black_point_correction()) > 0
            and getcfg("calibration.black_point_correction_choice.show")
            and not getcfg("calibration.black_point_correction.auto")
        ):
            ok = lang.getstr("turn_on") if "c" in v else lang.getstr("turn_off")
            title = "calibration.black_point_correction"
            msg = "calibration.black_point_correction_choice"
            cancel = "setting.keep_current"
            dlg = ConfirmDialog(
                self,
                title=lang.getstr(title),
                msg=lang.getstr(msg),
                ok=ok,
                cancel=lang.getstr(cancel),
                bitmap=get_icon(32, "dialog-question"),
            )
            chk = wx.CheckBox(dlg, -1, lang.getstr("dialog.do_not_show_again"))
            dlg.Bind(
                wx.EVT_CHECKBOX,
                self.black_point_correction_choice_dialog_handler,
                id=chk.GetId(),
            )
            dlg.sizer3.Add(chk, flag=wx.TOP | wx.ALIGN_LEFT, border=12)
            dlg.sizer0.SetSizeHints(dlg)
            dlg.sizer0.Layout()
            result = dlg.ShowModal()
            dlg.Destroy()
            if result == wx.ID_OK:
                bkpt_corr = 1.0 if "c" in v else 0.0
                if not cal_changed and bkpt_corr != getcfg(
                    "calibration.black_point_correction"
                ):
                    self.cal_changed()
                setcfg("calibration.black_point_correction", bkpt_corr)
                self.update_controls(update_profile_name=False)
        self.update_profile_name()
        if v == "auto":
            wx.CallAfter(
                show_result_dialog,
                UnloggedInfo(lang.getstr("display.reset.info")),
                self,
            )

    def black_point_correction_choice_dialog_handler(self, event: wx.Event) -> None:
        """Handle the checkbox in the black point correction choice dialog.

        Args:
            event (wx.Event): The event triggered by the checkbox.
        """
        setcfg(
            "calibration.black_point_correction_choice.show",
            int(not event.GetEventObject().GetValue()),
        )

    def profile_type_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the profile type control.

        Args:
            event (wx.Event): The event triggered by the control.
        """
        if DEBUG and event:
            print(
                "[D] profile_type_ctrl_handler called for ID "
                f"{event.GetId()} {getevtobjname(event, self)} event type "
                f"{event.GetEventType()} {getevttype(event)}"
            )
        v = self.get_profile_type()
        lut_type = v in ("l", "x", "X")
        self.gamap_btn.Enable(lut_type)

        proftype_changed = False
        if v in ("l", "x", "X"):
            # XYZ LUT type
            if getcfg("profile.type") not in ("l", "x", "X"):
                # Disable black point compensation for LUT profiles
                setcfg("profile.black_point_compensation", 0)
                proftype_changed = True
        elif v in ("s", "S"):
            # Shaper + matrix type
            if getcfg("profile.type") not in ("s", "S"):
                # Enable black point compensation for shaper profiles
                setcfg("profile.black_point_compensation", 1)
        else:
            setcfg("profile.black_point_compensation", 0)
        if v in ("s", "S", "g", "G") and getcfg("profile.type") not in (
            "s",
            "S",
            "g",
            "G",
        ):
            proftype_changed = True
        self.update_bpc()
        self.profile_quality_ctrl.Enable(v not in ("g", "G"))
        if v in ("g", "G"):
            self.profile_quality_ctrl.SetValue(3)
            self.profile_quality_info.SetLabel(lang.getstr("calibration.quality.high"))
        if v != getcfg("profile.type"):
            self.profile_settings_changed()
        setcfg("profile.type", v)
        if hasattr(self, "gamapframe"):
            self.gamapframe.update_controls()
        self.set_default_testchart(force=proftype_changed)
        self.update_profile_name()
        if event:
            self.check_testchart_patches_amount()

    def check_testchart_patches_amount(self) -> None:
        """Check if the selected testchart has at least the recommended amount of patches.

        Give user the choice to use the recommended amount if patch count is lower.
        """  # noqa: E501
        recommended = {
            "G": 6,
            "g": 6,
            "l": 125,
            "lh": 125,
            "S": 12,
            "s": 12,
            "X": 73,
            "Xh": 73,
            "x": 73,
            "xh": 73,
        }
        # lower quality actually needs *higher* patchcount while high quality
        # can get away with fewer patches and still improved result
        recommended = recommended.get(
            self.get_profile_type() + self.get_profile_quality(),
            recommended[self.get_profile_type()],
        )
        patches = int(self.testchart_patches_amount.GetLabel())
        if recommended <= patches or is_ccxx_testchart():
            return

        self.profile_quality_ctrl.Disable()
        dlg = ConfirmDialog(
            self,
            msg=lang.getstr("profile.testchart_recommendation"),
            ok=lang.getstr("OK"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, "dialog-question"),
        )
        result = dlg.ShowModal()
        self.profile_quality_ctrl.Enable(
            not getcfg("profile.update") and self.get_profile_type() not in ("g", "G")
        )
        dlg.Destroy()
        if result != wx.ID_OK:
            return
        setcfg(
            "testchart.auto_optimize",
            max(
                config.VALID_VALUES["testchart.auto_optimize"][1],
                round(colormath.cbrt(recommended)),
            ),
        )
        self.set_testchart("auto")

    def measurement_file_check_auto_handler(self, event: wx.Event) -> None:
        """Handle the automatic measurement file check menu item.

        Args:
            event (wx.Event): The event triggered by the menu item.
        """
        if not getcfg("ti3.check_sanity.auto"):
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("measurement_file.check_sanity.auto.warning"),
                ok=lang.getstr("ok"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-warning"),
                log=False,
            )
            result = dlg.ShowModal()
            dlg.Destroy()
            if result != wx.ID_OK:
                self.menuitem_measurement_file_check_auto.Check(False)
                return
        setcfg(
            "ti3.check_sanity.auto",
            int(self.menuitem_measurement_file_check_auto.IsChecked()),
        )

    def measurement_file_check_handler(self, event: wx.Event) -> None:
        """Handle the measurement file check menu item.

        Args:
            event (wx.Event): The event triggered by the menu item.
        """
        # select measurement data (ti3 or profile)
        path = None
        default_dir, default_file = get_verified_path("last_ti3_path")
        dlg = wx.FileDialog(
            self,
            lang.getstr("measurement_file.choose"),
            defaultDir=default_dir,
            defaultFile=default_file,
            wildcard=lang.getstr("filetype.icc_ti3") + "|*.icc;*.icm;*.ti3",
            style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
        )
        dlg.Center(wx.BOTH)
        result = dlg.ShowModal()
        if result == wx.ID_OK:
            path = dlg.GetPath()
        dlg.Destroy()
        if not path:
            return
        if not os.path.exists(path):
            show_result_dialog(Error(lang.getstr("file.missing", path)), self)
            return
        # Get filename and extension of file
        filename, ext = os.path.splitext(path)
        if ext.lower() != ".ti3":
            try:
                profile = ICCProfile(path)
            except (OSError, ICCProfileInvalidError):
                show_result_dialog(
                    Error(lang.getstr("profile.invalid") + "\n" + path), self
                )
                return
            if (profile.tags.get("CIED", "") or profile.tags.get("targ", ""))[
                0:4
            ] != "CTI3":
                show_result_dialog(
                    Error(lang.getstr("profile.no_embedded_ti3") + "\n" + path),
                    self,
                )
                return
            ti3 = BytesIO(profile.tags.get("CIED", "") or profile.tags.get("targ", ""))
        else:
            profile = None
            try:
                ti3 = open(path, "rb")  # noqa: SIM115
            except Exception:
                show_result_dialog(Error(lang.getstr("error.file.open", path)), self)
                return
        setcfg("last_ti3_path", path)
        ti3 = CGATS(ti3)
        if not self.measurement_file_check_confirm(ti3, True):
            return
        if not ti3.modified:
            show_result_dialog(UnloggedInfo(lang.getstr("errors.none_found")), self)
            return

        if profile:
            # Regenerate the profile?
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("profile.confirm_regeneration"),
                ok=lang.getstr("ok"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-information"),
            )
            dlg.Center()
            result = dlg.ShowModal()
            if result == wx.ID_OK:
                self.worker.wrapup(False)
                tmp_working_dir = self.worker.create_tempdir()
                if isinstance(tmp_working_dir, Exception):
                    show_result_dialog(tmp_working_dir, self)
                    return
                profile.tags.targ = TextType(b"text\0\0\0\0" + ti3 + b"\0", b"targ")
                profile.tags.DevD = profile.tags.CIED = profile.tags.targ
                tmp_path = os.path.join(tmp_working_dir, os.path.basename(path))
                profile.write(tmp_path)
                self.create_profile_handler(None, tmp_path, True)
            return

        # No profile, just save the TI3
        dlg = wx.FileDialog(
            self,
            lang.getstr("save_as"),
            os.path.dirname(path),
            os.path.basename(path),
            wildcard=lang.getstr("filetype.ti3") + "|*.ti3",
            style=wx.SAVE | wx.FD_OVERWRITE_PROMPT,
        )
        dlg.Center(wx.BOTH)
        result = dlg.ShowModal()
        path = dlg.GetPath()
        dlg.Destroy()
        if result != wx.ID_OK:
            return
        if not waccess(path, os.W_OK):
            show_result_dialog(
                Error(lang.getstr("error.access_denied.write", path)),
                self,
            )
            return
        try:
            ti3.write(path)
        except OSError as exception:
            show_result_dialog(exception, self)

    def measurement_file_check_confirm(
        self,
        ti3: None | CGATS = None,
        force: bool = False,
        parent: None | wx.Window = None,
    ) -> bool | tuple[CGATS, list[int]]:
        """Check the sanity of a measurement file (TI3).

        Args:
            ti3 (None | CGATS, optional): The CGATS object representing the TI3
                file.
            force (bool, optional): If True, skip the sanity check dialog.
                Defaults to False.
            parent (None | wx.Window, optional): The parent window for the
                dialog. Defaults to None.

        Returns:
            bool: True if the TI3 file is valid or if the user confirms the
                changes, False if the user cancels the dialog. A tuple of
                (CGATS, list[int]) is returned if the TI3 file was modified
                and saved successfully.
        """
        if not getcfg("ti3.check_sanity.auto") and not force:
            return True
        if not ti3:
            profile_save_path = self.worker.tempdir
            if profile_save_path and os.path.isdir(profile_save_path):
                profile_name = getcfg("profile.name.expanded")
                ti3 = os.path.join(
                    profile_save_path,
                    f"{make_argyll_compatible_path(profile_name)}.ti3",
                )
                if not os.path.isfile(ti3):
                    ti3 = None
            if not ti3:
                # Let the caller handle missing files
                return True
        try:
            if not isinstance(ti3, CGATS):
                ti3 = CGATS(ti3)
            ti3_1 = verify_ti1_rgb_xyz(ti3)
        except (OSError, CGATSError) as exception:
            show_result_dialog(exception, self)
            return False
        suspicious = check_ti3(ti3_1)
        if not suspicious:
            return True
        self.Show(start_timers=False)
        dlg = MeasurementFileCheckSanityDialog(parent or self, ti3_1, suspicious, force)
        result = dlg.ShowModal()
        if result == wx.ID_OK:
            indexes = []
            for index in range(dlg.grid.GetNumberRows()):
                if dlg.grid.GetCellValue(index, 0) == "":
                    indexes.insert(0, index)
            data = ti3_1.queryv1("DATA")
            removed = []
            for index in indexes:
                removed.insert(0, data.pop(dlg.suspicious_items[index]))
            for item in removed:
                print(f"Removed patch #{item.key:.0f} from TI3: {item}")
            for index in dlg.mods:
                fields = dlg.mods[index]
                if index not in indexes:
                    item = dlg.suspicious_items[index]
                    for field in fields:
                        value = fields[field]
                        old = item[field]
                        if old != value:
                            item[field] = value
                            print(
                                f"Updated patch #{item.SAMPLE_ID} in TI3: "
                                f"{field} {old:.4f} \u2192 {value:.4f}"
                            )
        dlg.Destroy()
        if result == wx.ID_CANCEL:
            return False
        if result == wx.ID_OK and ti3.modified:
            if ti3.filename and os.path.exists(ti3.filename) and not force:
                try:
                    ti3.write()
                except OSError as exception:
                    show_result_dialog(exception, self)
                    return False
                print("Written updated TI3 to", ti3.filename)
            return removed, ti3
        return True

    def profile_name_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the profile name text control.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        debug_print(
            "[D] profile_name_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        oldval = self.profile_name_textctrl.GetValue()
        if not self.check_profile_name() or len(oldval) > 80:
            wx.Bell()
            x = self.profile_name_textctrl.GetInsertionPoint()
            if oldval == "":
                newval = DEFAULTS.get("profile.name", "")
            else:
                newval = re.sub(r"[\\/:;*?\"<>|]+", "", oldval).lstrip("-")[:80]
                # Windows silently strips any combination of trailing spaces and dots
                newval = newval.rstrip(" .")
            self.profile_name_textctrl.ChangeValue(newval)
            self.profile_name_textctrl.SetInsertionPoint(
                x - (len(oldval) - len(newval))
            )
        self.update_profile_name()

    def create_profile_name_btn_handler(self, event: wx.Event) -> None:
        """Create profile name from display and measurement settings.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        self.update_profile_name()

    def create_session_archive_handler(self, event: wx.Event) -> None:
        """Create 7z or ZIP archive of the currently selected profile folder.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        filename = getcfg("calibration.file", False)
        if not filename:
            return
        path_name, ext = os.path.splitext(filename)
        # Check for 7-Zip
        sevenzip = get_program_file("7z", "7-zip")
        file_format = "7z" if sevenzip else "zip"
        wildcard = "{}|*.{}".format(lang.getstr(f"filetype.{file_format}"), file_format)
        if file_format == "7z":
            wildcard = "{}|{}|*.zip".format(wildcard, lang.getstr("filetype.zip"))
        wildcard = "{}|{}|*.tgz".format(wildcard, lang.getstr("filetype.tgz"))
        # Ask where to save archive
        default_dir, default_file = get_verified_path("last_archive_save_path")
        dlg = wx.FileDialog(
            self,
            lang.getstr("archive.create"),
            default_dir,
            f"{os.path.basename(path_name)}.{file_format}",
            wildcard=wildcard,
            style=wx.SAVE | wx.FD_OVERWRITE_PROMPT,
        )
        dlg.Center(wx.BOTH)
        result = dlg.ShowModal()
        archive_path = dlg.GetPath()
        if sevenzip and dlg.GetFilterIndex():
            # ZIP or TGZ
            sevenzip = None
        dlg.Destroy()
        if result != wx.ID_OK:
            return
        setcfg("last_archive_save_path", archive_path)
        dirname = os.path.dirname(filename)
        dirfilenames = [
            os.path.join(dirname, filename) for filename in os.listdir(dirname)
        ]
        dirfilenames.sort()
        # Select filenames
        filenames = (
            safe_glob(path_name + "*")
            + safe_glob(os.path.join(dirname, "*.ccmx"))
            + safe_glob(os.path.join(dirname, "*.ccss"))
            + safe_glob(os.path.join(dirname, "0_16.ti1"))
            + safe_glob(os.path.join(dirname, "0_16.ti3"))
            + safe_glob(os.path.join(dirname, "0_16.log"))
        )
        # Remove duplicates & sort
        filenames = sorted(set(filenames))
        lut3d_ext = [
            "." + strtr(lut3d_format, {"eeColor": "txt", "madVR": "3dlut"})
            for lut3d_format in [
                f
                for f in config.VALID_VALUES["3dlut.format"]
                if f not in ("icc", "icm", "png")
            ]
        ]
        has_3dlut = False
        for filename in filenames:
            if os.path.splitext(filename)[1].lower() in lut3d_ext:
                has_3dlut = True
                break
        if has_3dlut:
            # Should 3D LUT files be included?
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("archive.include_3dluts"),
                ok=lang.getstr("no"),
                alt=lang.getstr("yes"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-question"),
            )
            result = dlg.ShowModal()
            if result == wx.ID_CANCEL:
                return
            if result != wx.ID_OK:
                # Include 3D LUTs
                lut3d_ext = None
        self.worker.interactive = False
        self.worker.start(
            self.create_session_archive_consumer,
            self.create_session_archive_producer,
            wargs=(
                dirname,
                dirfilenames,
                filenames,
                archive_path,
                lut3d_ext if has_3dlut else None,
                sevenzip,
            ),
            progress_msg=lang.getstr("archive.create"),
            stop_timers=False,
            cancelable=bool(sevenzip),
            fancy=False,
        )

    def create_session_archive_producer(
        self,
        dirname: str,
        dirfilenames: list[str],
        filenames: list[str],
        archive_path: str,
        exclude_ext: None | list,
        sevenzip: None | str,
    ) -> bool | Exception:
        """Create session archive.

        Args:
            dirname (str): The directory name where the files are located.
            dirfilenames (list): List of all filenames in the directory.
            filenames (list): List of filenames to include in the archive.
            archive_path (str): The path where the archive will be saved.
            exclude_ext (None | list): List of file extensions to exclude from
                the archive.
            sevenzip (None | str): Path to the 7-Zip executable, or None to
                create a ZIP archive.

        Returns:
            bool | Exception: True if the archive was created successfully, or
                an Exception if an error occurred.
        """
        if sevenzip:
            # Create 7z archive
            if filenames == dirfilenames:
                # Add whole folder to archive, so that the 7z archive
                # has one folder in it containing all files
                filenames = [dirname]
            if os.path.isfile(archive_path):
                os.remove(archive_path)
            args = ["a", "-y"]
            if exclude_ext:
                args.extend(f"-xr!*{ext}" for ext in exclude_ext)
            return self.worker.exec_cmd(
                sevenzip, [*args, archive_path, *filenames], capture_output=True
            )
        # Create gzipped TAR or ZIP archive
        dirbasename = ""
        if filenames == dirfilenames:
            # Add whole folder to archive, so that the ZIP archive
            # has one folder in it containing all files
            dirbasename = os.path.basename(dirname)
        if archive_path.lower().endswith(".tgz") or archive_path.lower().endswith(
            ".tar.gz"
        ):
            # Create gzipped tar archive
            archive = TarFileProper.open(archive_path, "w:gz", encoding="UTF-8")
            writefile = archive.add
        else:
            archive = zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED)
            writefile = archive.write
        try:
            with archive:
                for filename in filenames:
                    if (
                        exclude_ext
                        and os.path.splitext(filename)[1].lower() in exclude_ext
                    ):
                        continue
                    writefile(
                        filename,
                        os.path.join(dirbasename, os.path.basename(filename)),
                    )
        except Exception as exception:
            return exception
        else:
            return True

    def create_session_archive_consumer(self, result: bool | Exception) -> None:
        """Handle the result of the session archive creation.

        Args:
            result (bool | Exception): The result of the archive creation.
        """
        if not result:
            result = UnloggedError("".join(self.worker.errors))
        if isinstance(result, Exception):
            show_result_dialog(result, parent=self)

    def profile_save_path_btn_handler(self, event: wx.Event) -> None:
        """Handle the profile save path button.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        default_path = os.path.join(*get_verified_path("profile.save_path"))
        profile_name = getcfg("profile.name.expanded")
        dlg = wx.DirDialog(
            self,
            lang.getstr("dialog.set_profile_save_path", profile_name),
            defaultPath=default_path,
        )
        dlg.Center(wx.BOTH)
        if dlg.ShowModal() != wx.ID_OK:
            dlg.Destroy()
            return

        path = dlg.GetPath()
        profile_save_dir = os.path.join(path, profile_name)
        if not os.path.isdir(profile_save_dir):
            os.makedirs(profile_save_dir, exist_ok=True)
        if not waccess(os.path.join(profile_save_dir, profile_name), os.W_OK):
            show_result_dialog(
                Error(lang.getstr("error.access_denied.write", path)), self
            )
            return
        with contextlib.suppress(Exception):
            os.rmdir(profile_save_dir)
        setcfg("profile.save_path", path)
        self.update_profile_name()
        dlg.Destroy()

    def profile_name_info_btn_handler(self, event: wx.Event) -> None:
        """Show a tooltip window with profile name placeholders and their meanings."""
        if not hasattr(self, "profile_name_tooltip_window"):
            self.profile_name_tooltip_window = TooltipWindow(
                self,
                msg=self.profile_name_info(),
                cols=2,
                title=lang.getstr("profile.name"),
                bitmap=get_icon(32, "dialog-information"),
            )
        else:
            self.profile_name_tooltip_window.Show()
            self.profile_name_tooltip_window.Raise()

    def profile_name_info(self) -> str:
        """Return a string with profile name placeholders and their meanings.

        Returns:
            str: A formatted string with profile name placeholders and their
                meanings.
        """
        info = [
            f"%nn\t{lang.getstr('computer.name')}",
            f"%dn\t{lang.getstr('display')}",
            f"%dns\t{lang.getstr('display_short')}",
            f"%dnw\t{lang.getstr('display')} ({lang.getstr('windows_only')})",
            f"%dnws\t{lang.getstr('display_short')} ({lang.getstr('windows_only')})",
            f"%out\t{lang.getstr('display.output')}",
            f"%ds\t{lang.getstr('edid.serial')} ({lang.getstr('if_available')})",
            f"%crc32\t{lang.getstr('edid.crc32')} ({lang.getstr('if_available')})",
            f"%in\t{lang.getstr('instrument')}",
            f"%im\t{lang.getstr('measurement_mode')}",
            f"%wp\t{lang.getstr('whitepoint')}",
            f"%cb\t{lang.getstr('calibration.luminance')}",
            f"%cB\t{lang.getstr('calibration.black_luminance')}",
            f"%cg\t{lang.getstr('trc')}",
            f"%ca\t{lang.getstr('calibration.ambient_viewcond_adjust')}",
            f"%cf\t{lang.getstr('calibration.black_output_offset')}",
            f"%ck\t{lang.getstr('calibration.black_point_correction')}",
        ]
        if DEFAULTS["calibration.black_point_rate.enabled"]:
            info.append(f"%cA\t{lang.getstr('calibration.black_point_rate')}")
        info.extend(
            [
                f"%cq\t{lang.getstr('calibration.speed')}",
                f"%pq\t{lang.getstr('profile.quality')}",
                f"%pt\t{lang.getstr('profile.type')}",
                f"%tpa\t{lang.getstr('testchart.info')}",
            ]
        )
        return "{}\n{}".format(
            lang.getstr("profile.name.placeholders"), "\n".join(info)
        )

    def check_profile_b2a_hires(self, profile: ICCProfile) -> bool:
        """Check if profile is a LUT-type.

        If yes, if LUT is of high enough resolution when created by ArgyllCMS
        (we assume anything >= 17 to be ok) and give choice to generate hires
        tables if not.

        Args:
            profile (ICCProfile): The ICC profile to check.

        Returns:
            bool: True if hires B2A or no B2A, False otherwise.
        """
        if (
            "B2A0" in profile.tags
            and isinstance(profile.tags.B2A0, LUT16Type)
            and profile.tags.B2A0.clut_grid_steps < 17
            and profile.creator == b"argl"
        ):
            # Nope. Not allowing to install. Offer to re-generate B2A
            # tables.
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("profile.b2a.lowres.warning"),
                bitmap=get_icon(32, "dialog-warning"),
            )
            choice = dlg.ShowModal()
            if choice == wx.ID_OK:
                self.profile_hires_b2a_handler(None, profile)
            return False
        return True

    def profile_hires_b2a_handler(
        self, event: wx.Event, profile: None | ICCProfile = None
    ) -> None:
        """Handle the profile B2A highres generation.

        Args:
            event (wx.Event): Event that triggered this handler.
            profile (None | ICCProfile, optional): The profile for which B2A
                tables should be generated. If None, a profile selection dialog
                will be shown to select a profile.
        """
        if not profile:
            profile = self.select_profile(
                title=lang.getstr("profile.b2a.hires"), ignore_current_profile=True
            )
        if not profile:
            return
        if not ("A2B0" in profile.tags or "A2B1" in profile.tags):
            result = Error(
                lang.getstr(
                    "profile.required_tags_missing",
                    f"A2B0 {lang.getstr('or')} A2B1",
                )
            )
        elif (
            "A2B0" in profile.tags and not isinstance(profile.tags.A2B0, LUT16Type)
        ) or ("A2B1" in profile.tags and not isinstance(profile.tags.A2B1, LUT16Type)):
            result = Error(lang.getstr("profile.required_tags_missing", "LUT16Type"))
        elif profile.connectionColorSpace not in (b"XYZ", b"Lab"):
            result = Error(
                lang.getstr(
                    "profile.unsupported",
                    (profile.connectionColorSpace, profile.connectionColorSpace),
                )
            )
        else:
            result = None
        if result:
            show_result_dialog(result, self)
        else:
            self.interactive = False
            # self.profile_hires_b2a_consumer(
            #     self.worker.update_profile_B2A(profile),
            #     profile
            # )
            self.worker.start(
                self.profile_hires_b2a_consumer,
                self.worker.update_profile_B2A,
                cargs=(profile,),
                wargs=(profile,),
                wkwargs={"clutres": getcfg("profile.b2a.hires.size")},
            )

    def profile_hires_b2a_consumer(
        self, result: str | Exception, profile: ICCProfile
    ) -> None:
        """Consumer for profile B2A highres generation.

        Args:
            result (str | Exception): Result of the B2A generation.
            profile (ICCProfile): The profile for which B2A tables were
                generated.
        """
        self.start_timers()
        if isinstance(result, Exception):
            show_result_dialog(result, self)
        elif result:
            if not profile.filename or not os.path.isfile(profile.filename):
                # Let the user choose a location for the profile
                default_dir, default_file = os.path.split(profile.filename)
                dlg = wx.FileDialog(
                    self,
                    lang.getstr("save_as"),
                    default_dir,
                    default_file,
                    wildcard=lang.getstr("filetype.icc") + "|*" + PROFILE_EXT,
                    style=wx.SAVE | wx.FD_OVERWRITE_PROMPT,
                )
                dlg.Center(wx.BOTH)
                result = dlg.ShowModal()
                profile_save_path = dlg.GetPath()
                dlg.Destroy()
                if result != wx.ID_OK:
                    return
                filename, ext = os.path.splitext(profile_save_path)
                if ext.lower() not in (".icc", ".icm"):
                    profile_save_path += PROFILE_EXT
                profile.setDescription(os.path.basename(filename))
            else:
                result = wx.ID_OK
                profile_save_path = profile.filename
            if result == wx.ID_OK:
                if not waccess(profile_save_path, os.W_OK):
                    show_result_dialog(
                        Error(
                            lang.getstr("error.access_denied.write", profile_save_path)
                        ),
                        self,
                    )
                    return
                profile.calculate_id()
                profile.write(profile_save_path)
                if profile_save_path == get_current_profile_path():
                    self.lut3d_update_b2a_controls()
                self.install_profile_handler(
                    None, profile_save_path, install_3dlut=False
                )
        else:
            show_result_dialog(lang.getstr("error.profile.file_not_created"), self)

    def create_profile_handler(
        self, event: wx.Event, path: None | str = None, skip_ti3_check: bool = False
    ) -> None:
        """Create profile from existing measurements.

        Args:
            event (wx.Event): Event that triggered this handler.
            path (None | str, optional): Path to the measurement file. If None,
                a file dialog will be shown to select the measurement file.
            skip_ti3_check (bool): If True, skip the sanity check for the TI3
                file.
        """
        if not check_set_argyll_bin():
            return
        if self.check_show_macos_bugs_warning(cal=False) is False:
            return
        if path is None:
            selectedpaths = []
            # select measurement data (ti3 or profile)
            default_dir, default_file = get_verified_path("last_ti3_path")
            dlg = wx.FileDialog(
                self,
                lang.getstr("create_profile"),
                defaultDir=default_dir,
                defaultFile=default_file,
                wildcard=lang.getstr("filetype.icc_ti3") + "|*.icc;*.icm;*.ti3",
                style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST | wx.FD_MULTIPLE,
            )
            dlg.Center(wx.BOTH)
            result = dlg.ShowModal()
            if result == wx.ID_OK:
                selectedpaths = dlg.GetPaths()
            dlg.Destroy()
        elif path:
            selectedpaths = [path]
        collected_ti3s = []
        for path_ in selectedpaths:
            if not os.path.exists(path_):
                InfoDialog(
                    self,
                    msg=lang.getstr("file.missing", path_),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                return
            tags = {}
            ti3_lines = []
            # Get filename and extension of source file
            source_filename, source_ext = os.path.splitext(path_)
            if source_ext.lower() != ".ti3":
                try:
                    profile = ICCProfile(path_)
                except (OSError, ICCProfileInvalidError):
                    InfoDialog(
                        self,
                        msg=f"{lang.getstr('profile.invalid')}\n{path_}",
                        ok=lang.getstr("ok"),
                        bitmap=get_icon(32, "dialog-error"),
                    )
                    return
                if (profile.tags.get("CIED", "") or profile.tags.get("targ", ""))[
                    0:4
                ] != "CTI3":
                    InfoDialog(
                        self,
                        msg=f"{lang.getstr('profile.no_embedded_ti3')}\n{path_}",
                        ok=lang.getstr("ok"),
                        bitmap=get_icon(32, "dialog-error"),
                    )
                    return
                with BytesIO(
                    profile.tags.get("CIED", "") or profile.tags.get("targ", "")
                ) as ti3:
                    ti3_lines = [line.strip() for line in ti3]
                # Preserve custom tags
                for tagname in ("mmod", "meta"):
                    if tagname in profile.tags:
                        tags[tagname] = profile.tags[tagname]
            else:
                try:
                    with open(path_, "rb") as ti3:
                        ti3_lines = [line.strip() for line in ti3]
                except Exception:
                    InfoDialog(
                        self,
                        msg=lang.getstr("error.file.open", path_),
                        ok=lang.getstr("ok"),
                        bitmap=get_icon(32, "dialog-error"),
                    )
                    return
            if b"CAL" not in ti3_lines:
                dlg = ConfirmDialog(
                    self,
                    msg=lang.getstr("dialog.ti3_no_cal_info"),
                    ok=lang.getstr("continue"),
                    cancel=lang.getstr("cancel"),
                    bitmap=get_icon(32, "dialog-warning"),
                )
                result = dlg.ShowModal()
                dlg.Destroy()
                if result != wx.ID_OK:
                    return
            collected_ti3s.append((path_, ti3_lines))
        if not collected_ti3s:
            return
        if len(collected_ti3s) > 1:
            source_filename = os.path.splitext(DEFAULTS["last_ti3_path"])[0]
            source_ext = ".ti3"
        path = collected_ti3s[0][0]
        is_tmp = False
        tmp_working_dir = self.worker.tempdir
        if tmp_working_dir:
            if sys.platform == "win32":
                if path.lower().startswith(tmp_working_dir.lower()):
                    is_tmp = True
            elif path.startswith(tmp_working_dir):
                is_tmp = True
        if is_tmp:
            default_dir, default_file = get_verified_path("last_ti3_path")
        else:
            default_dir, default_file = os.path.split(path)
            setcfg("last_ti3_path", path)
        # let the user choose a location for the profile
        dlg = wx.FileDialog(
            self,
            lang.getstr("save_as"),
            default_dir,
            os.path.basename(source_filename) + PROFILE_EXT,
            wildcard=lang.getstr("filetype.icc") + "|*" + PROFILE_EXT,
            style=wx.SAVE | wx.FD_OVERWRITE_PROMPT,
        )
        dlg.Center(wx.BOTH)
        result = dlg.ShowModal()
        profile_save_path = os.path.split(dlg.GetPath())
        profile_save_path = os.path.join(
            profile_save_path[0], make_argyll_compatible_path(profile_save_path[1])
        )
        dlg.Destroy()
        if result != wx.ID_OK:
            return

        if not waccess(profile_save_path, os.W_OK):
            show_result_dialog(
                Error(lang.getstr("error.access_denied.write", profile_save_path)),
                self,
            )
            return
        filename, ext = os.path.splitext(profile_save_path)
        if ext.lower() not in (".icc", ".icm"):
            profile_save_path += PROFILE_EXT
            if os.path.exists(profile_save_path):
                dlg = ConfirmDialog(
                    self,
                    msg=lang.getstr("dialog.confirm_overwrite", (profile_save_path)),
                    ok=lang.getstr("overwrite"),
                    cancel=lang.getstr("cancel"),
                    bitmap=get_icon(32, "dialog-warning"),
                )
                result = dlg.ShowModal()
                dlg.Destroy()
                if result != wx.ID_OK:
                    return
        setcfg("last_cal_or_icc_path", profile_save_path)
        setcfg("last_icc_path", profile_save_path)
        # get filename and extension of target file
        profile_name = os.path.basename(os.path.splitext(profile_save_path)[0])
        # create temporary working dir
        tmp_working_dir = self.worker.create_tempdir()
        if isinstance(tmp_working_dir, Exception):
            self.worker.wrapup(False)
            show_result_dialog(tmp_working_dir, self)
            return
        # Copy ti3 to temp dir
        ti3_tmp_path = os.path.join(
            tmp_working_dir, make_argyll_compatible_path(f"{profile_name}.ti3")
        )
        if len(collected_ti3s) > 1:
            # Collect files for averaging
            collected_paths = []
            for ti3_path, ti3_lines in collected_ti3s:
                collected_path = os.path.join(
                    tmp_working_dir, os.path.basename(ti3_path)
                )
                with open(collected_path, "wb") as ti3_file:
                    ti3_file.write(b"\n".join(ti3_lines))
                collected_paths.append(collected_path)
            # Average the TI3 files
            args = ["-v", *collected_paths, ti3_tmp_path]
            cmd = get_argyll_util("average")
            result = self.worker.exec_cmd(
                cmd, args, capture_output=True, skip_scripts=True
            )
            for collected_path in collected_paths:
                os.remove(collected_path)
            if isinstance(result, Exception) or not result:
                self.worker.wrapup(False)
                show_result_dialog(result or Error("\n".join(self.worker.errors)), self)
                return
            path = ti3_tmp_path
        self.worker.options_dispcal = []
        self.worker.options_targen = []
        display_name = None
        display_manufacturer = None
        try:
            if source_ext.lower() == ".ti3":
                if path != ti3_tmp_path:
                    shutil.copyfile(path, ti3_tmp_path)
                # Get dispcal options if present
                (options_dispcal, options_colprof) = get_options_from_ti3(path)
                self.worker.options_dispcal = ["-" + arg for arg in options_dispcal]
                arg = get_arg("M", options_colprof)
                if arg:
                    display_name = arg[1][2:].strip('"')
                arg = get_arg("A", options_colprof)
                if arg:
                    display_manufacturer = arg[1][2:].strip('"')
            else:
                # Binary mode because we want to avoid automatic
                # newlines conversion
                with open(ti3_tmp_path, "wb") as ti3:
                    ti3.write(
                        profile.tags.get("CIED", b"") or profile.tags.get("targ", b"")
                    )
                # Get dispcal options if present
                self.worker.options_dispcal = [
                    "-" + arg for arg in get_options_from_profile(profile)[0]
                ]
                if "dmdd" in profile.tags:
                    display_name = profile.getDeviceModelDescription()
                if "dmnd" in profile.tags:
                    display_manufacturer = profile.getDeviceManufacturerDescription()
                if is_tmp and path != ti3_tmp_path:
                    profile.close()
                    os.remove(path)
            ti3 = CGATS(ti3_tmp_path)
            if ti3.queryv1("COLOR_REP") and ti3.queryv1("COLOR_REP")[:3] == b"RGB":
                self.worker.options_targen = ["-d3"]
        except Exception as exception:
            handle_error(
                Error(
                    "Error - temporary .ti3 file could not "
                    "be created: " + str(exception)
                ),
                parent=self,
            )
            self.worker.wrapup(False)
            return
        setcfg("calibration.file.previous", None)
        print("-" * 80)
        if not skip_ti3_check and not self.measurement_file_check_confirm(ti3):
            self.worker.wrapup(False)
            return
        # Run colprof
        self.worker.interactive = False
        self.worker.start(
            self.profile_finish,
            self.worker.create_profile,
            ckwargs={
                "profile_path": profile_save_path,
                "failure_msg": lang.getstr("error.profile.file_not_created"),
                "install_3dlut": getcfg("3dlut.create"),
            },
            wkwargs={
                "dst_path": profile_save_path,
                "display_name": display_name,
                "display_manufacturer": display_manufacturer,
                "tags": tags,
            },
            progress_msg=lang.getstr("create_profile"),
        )

    def create_profile_from_edid(self, event: wx.Event) -> None:
        """Create profile from EDID.

        Args:
            event (wx.Event): A wx.Event object triggering this method.
        """
        edid = self.worker.get_display_edid()
        default_file = (
            edid.get("monitor_name", edid.get("ascii", str(edid["product_id"])))
            + PROFILE_EXT
        )
        default_dir = get_verified_path(
            None, os.path.join(getcfg("profile.save_path"), default_file)
        )[0]
        # let the user choose a location for the profile
        dlg = wx.FileDialog(
            self,
            lang.getstr("save_as"),
            default_dir,
            default_file,
            wildcard=lang.getstr("filetype.icc") + "|*" + PROFILE_EXT,
            style=wx.SAVE | wx.FD_OVERWRITE_PROMPT,
        )
        dlg.Center(wx.BOTH)
        result = dlg.ShowModal()
        profile_save_path = os.path.split(dlg.GetPath())
        profile_save_path = os.path.join(
            profile_save_path[0], make_argyll_compatible_path(profile_save_path[1])
        )
        dlg.Destroy()
        if result != wx.ID_OK:
            return

        if not waccess(profile_save_path, os.W_OK):
            show_result_dialog(
                Error(lang.getstr("error.access_denied.write", profile_save_path)),
                self,
            )
            return
        profile = ICCProfile.from_edid(edid)
        try:
            profile.write(profile_save_path)
        except Exception as exception:
            InfoDialog(
                self,
                msg=str(exception),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
        else:
            if getcfg("profile.create_gamut_views"):
                print("-" * 80)
                print(lang.getstr("gamut.view.create"))
                self.worker.interactive = False
                self.worker.start(
                    self.create_profile_from_edid_finish,
                    self.worker.calculate_gamut,
                    cargs=(profile,),
                    wargs=(profile_save_path,),
                    progress_msg=lang.getstr("gamut.view.create"),
                    resume=False,
                )
            else:
                self.create_profile_from_edid_finish(True, profile)

    def create_profile_from_edid_finish(
        self,
        result: bool | Exception,
        profile: ICCProfile,
    ) -> None:
        """Finish creating profile from EDID.

        Args:
            result (bool | Exception): Result of the gamut calculation.
            profile (ICCProfile): The created ICC profile.
        """
        if isinstance(result, Exception):
            show_result_dialog(result, self)
        elif result:
            if isinstance(result, tuple):
                profile.set_gamut_metadata(result[0], result[1])
                prefix = profile.tags.meta.getvalue("prefix", b"", None)
                if isinstance(prefix, bytes):
                    prefix = prefix.decode("utf-8")
                prefixes = prefix.split(",")
                # Set license
                profile.tags.meta["License"] = getcfg("profile.license")
                # Set device ID
                device_id = self.worker.get_device_id(quirk=False)
                if device_id:
                    profile.tags.meta["MAPPING_device_id"] = device_id
                    prefixes.append("MAPPING_")
                    profile.tags.meta["prefix"] = ",".join(prefixes)
                profile.calculate_id()
                print("-" * 80)
            try:
                profile.write()
            except Exception as exception:
                show_result_dialog(exception, self)
                return
            self.profile_finish(
                True, profile.filename, install_3dlut=getcfg("3dlut.create")
            )

    def create_profile_name(self) -> str:
        """Replace placeholders in profile name with values from configuration.

        Returns:
            str: The profile name with placeholders replaced by actual values.
        """
        profile_name = self.profile_name_textctrl.GetValue()

        # Computername
        if "%nn" in profile_name:
            profile_name = profile_name.replace("%nn", str(platform.node()) or "\0")

        # Windows display name (EnumDisplayDevices / DeviceString)
        if "%dnws" in profile_name:
            display_win32_short = self.worker.get_display_name_short(False, False)
            profile_name = profile_name.replace("%dnws", display_win32_short or "\0")
        if "%dnw" in profile_name:
            display_win32 = self.worker.get_display_name(True, False)
            profile_name = profile_name.replace("%dnw", display_win32 or "\0")

        # EDID
        if "%ds" in profile_name or "%crc32" in profile_name:
            edid = self.worker.get_display_edid()
        # Serial
        if "%ds" in profile_name:
            serial = edid.get("serial_ascii", hex(edid.get("serial_32", 0))[2:])
            if serial and serial not in ("0", "1010101", "fffffff"):
                profile_name = profile_name.replace("%ds", serial)
            else:
                profile_name = profile_name.replace("%ds", "\0")
        # CRC32
        if "%crc32" in profile_name:
            raw_edid = edid.get("edid")
            if isinstance(raw_edid, str):
                raw_edid = raw_edid.encode("utf-8")
            if raw_edid:
                profile_name = profile_name.replace(
                    "%crc32", "%X" % (crc32(raw_edid) & 0xFFFFFFFF)
                )
            else:
                profile_name = profile_name.replace("%crc32", "\0")

        # Display name
        if "%dns" in profile_name:
            display_short = self.worker.get_display_name_short(False, True)
            profile_name = profile_name.replace("%dns", display_short or "\0")
        if "%dn" in profile_name:
            display = self.worker.get_display_name(True, True)
            profile_name = profile_name.replace("%dn", display or "\0")

        # Output #
        if config.is_virtual_display():
            output = "\0"
        else:
            output = "#{}".format(getcfg("display.number"))
        profile_name = profile_name.replace("%out", output or "\0")

        # Instrument name
        if "%in" in profile_name:
            instrument = self.comport_ctrl.GetStringSelection()
            profile_name = profile_name.replace("%in", instrument or "\0")

        # Measurement mode
        if "%im" in profile_name:
            mode = ""
            measurement_mode = self.get_measurement_mode()
            if measurement_mode:
                if "c" in measurement_mode:
                    mode += lang.getstr("measurement_mode.refresh")
                elif "l" in measurement_mode:
                    mode += lang.getstr("measurement_mode.lcd")
                if "p" in measurement_mode:
                    if mode:
                        mode += "-"
                    mode += lang.getstr("projector")
                if "V" in measurement_mode:
                    if mode:
                        mode += "-"
                    mode += lang.getstr("measurement_mode.adaptive")
                if "H" in measurement_mode:
                    if mode:
                        mode += "-"
                    mode += lang.getstr("measurement_mode.highres")
            else:
                mode += lang.getstr("default")
            profile_name = profile_name.replace("%im", mode)

        trc = self.get_trc()
        do_cal = self.interactive_display_adjustment_cb.GetValue() or trc

        # Whitepoint
        if "%wp" in profile_name:
            whitepoint = self.get_whitepoint()
            whitepoint_locus = self.get_whitepoint_locus()
            if isinstance(whitepoint, str):
                if whitepoint.find(",") < 0:
                    if whitepoint_locus == "t":
                        whitepoint = "D" + whitepoint
                    else:
                        whitepoint += "K"
                else:
                    whitepoint = "x ".join(whitepoint.split(",")) + "y"
            profile_name = profile_name.replace("%wp", (do_cal and whitepoint) or "\0")

        # Luminance
        if "%cb" in profile_name:
            luminance = self.get_luminance()
            profile_name = profile_name.replace(
                "%cb", "\0" if luminance is None or not do_cal else luminance + "cdm²"
            )

        # Black luminance
        if "%cB" in profile_name:
            black_luminance = self.get_black_luminance()
            profile_name = profile_name.replace(
                "%cB",
                (
                    "\0"
                    if black_luminance is None or not do_cal
                    else black_luminance + "cdm²"
                ),
            )

        # TRC / black output offset
        if "%cg" in profile_name or "%cf" in profile_name:
            black_output_offset = self.get_black_output_offset()

        # TRC
        if "%cg" in profile_name and trc:
            trc_type = self.get_trc_type()
            bt1886 = trc == "2.4" and trc_type == "G" and black_output_offset == "0"
            if bt1886:
                trc = "Rec. 1886"
            elif trc not in ("l", "709", "s", "240"):
                if trc_type == "G":
                    trc = "{} ({})".format(
                        trc, lang.getstr("trc.type.absolute").lower()
                    )
            else:
                trc = strtr(
                    trc, {"l": "L", "709": "Rec. 709", "s": "sRGB", "240": "SMPTE240M"}
                )
        profile_name = profile_name.replace("%cg", trc or "\0")

        # Ambient adjustment
        if "%ca" in profile_name:
            ambient = self.get_ambient()
            profile_name = profile_name.replace(
                "%ca", "\0" if ambient is None or not trc else ambient + "lx"
            )

        # Black output offset
        if "%cf" in profile_name:
            f = int(float(black_output_offset) * 100)
            profile_name = profile_name.replace("%cf", (f"{f:.0f}%") if trc else "\0")

        # Black point correction / rate
        if "%ck" in profile_name or "%cA" in profile_name:
            black_point_correction = self.get_black_point_correction()

        # Black point correction
        if "%ck" in profile_name:
            k = int(float(black_point_correction) * 100)
            auto = self.black_point_correction_auto_cb.GetValue()
            profile_name = profile_name.replace(
                "%ck",
                (
                    (str(k) + "% " if 0 < k < 100 else "")
                    + (lang.getstr("neutral") if k > 0 else "\0").lower()
                    if trc and not auto
                    else "\0"
                ),
            )

        # Black point rate
        if "%cA" in profile_name:
            black_point_rate = self.get_black_point_rate()
            if black_point_rate and float(black_point_correction) < 1 and trc:
                profile_name = profile_name.replace("%cA", black_point_rate)
            else:
                profile_name = profile_name.replace("%cA", "\0")

        # Calibration / profile quality
        if "%cq" in profile_name or "%pq" in profile_name:
            calibration_quality = self.get_calibration_quality()
            profile_quality = getcfg("profile.quality")
            aspects = {"c": calibration_quality if trc else "", "p": profile_quality}
            msgs = {"u": "VS", "h": "S", "m": "M", "l": "F", "v": "VF", "": "\0"}
            quality = {}
            if "%cq" in profile_name:
                quality["c"] = msgs[aspects["c"]]
            if "%pq" in profile_name:
                quality["p"] = msgs[aspects["p"]]
            if len(quality) == 2 and (
                quality["c"] == quality["p"] or quality["c"] == "\0"
            ):
                profile_name = re.sub(r"%cq\W*%pq", quality["p"], profile_name)
            for q in quality:
                profile_name = profile_name.replace(f"%{q}q", quality[q])

        # Profile type
        if "%pt" in profile_name:
            profile_type = {
                "G": "1xGamma+MTX",
                "g": "3xGamma+MTX",
                "l": "LabLUT",
                "S": "1xCurve+MTX",
                "s": "3xCurve+MTX",
                "X": "XYZLUT+MTX",
                "x": "XYZLUT",
            }.get(self.get_profile_type())
            profile_name = profile_name.replace("%pt", profile_type or "\0")

        # Amount of test patches
        if "%tpa" in profile_name:
            profile_name = profile_name.replace(
                "%tpa", self.testchart_patches_amount.GetLabel()
            )

        # Date / time
        directives = (
            "a",
            "A",
            "b",
            "B",
            "d",
            "H",
            "I",
            "j",
            "m",
            "M",
            "p",
            "S",
            "U",
            "w",
            "W",
            "y",
            "Y",
        )
        for directive in directives:
            if f"%{directive}" in profile_name:
                with contextlib.suppress(UnicodeDecodeError):
                    profile_name = profile_name.replace(
                        f"%{directive}", strftime(f"%{directive}")
                    )

        # All whitespace to space
        profile_name = re.sub(r"\s", " ", profile_name)

        # Get rid of inserted NULL bytes
        # Try to keep spacing intact
        if "\0" in profile_name:
            profile_name = re.sub(r"^(\0[_\- ]?)+|([_\- ]?\0)+$", "", profile_name)
            # Surrounded by underscores
            while "_\0" in profile_name or "\0_" in profile_name:
                while re.search(r"_\0+_", profile_name):
                    profile_name = re.sub(r"_\0+_", "_", profile_name)
                profile_name = re.sub(r"_\0+", "_", profile_name)
                profile_name = re.sub(r"\0+_", "_", profile_name)
            # Surrounded by dashes
            while "-\0" in profile_name or "\0-" in profile_name:
                while re.search(r"-\0+-", profile_name):
                    profile_name = re.sub(r"-\0+-", "-", profile_name)
                profile_name = re.sub(r"-\0+", "-", profile_name)
                profile_name = re.sub(r"\0+-", "-", profile_name)
            # Surrounded by whitespace
            while " \0" in profile_name or "\0 " in profile_name:
                while re.search(r" \0+ ", profile_name):
                    profile_name = re.sub(r" \0+ ", " ", profile_name)
                profile_name = re.sub(r" \0+", " ", profile_name)
                profile_name = re.sub(r"\0+ ", " ", profile_name)
            profile_name = re.sub(r"\0+", "", profile_name)

        # Windows silently strips any combination of trailing spaces and dots
        profile_name = profile_name.rstrip(" .")

        # Get rid of characters considered invalid for filenames.
        # Also strip leading dashes which might trick Argyll tools into
        # mistaking parts of the profile name as an option parameter
        profile_name = re.sub(r"[\\/:;*?\"<>|]+", "_", profile_name).lstrip("-")

        # Windows: MAX_PATH = 260, e.g. C:\256-char-path<NUL>
        # Subtracting NUL and the four-char extension (e.g. .icm) leaves us
        # with 255 characters, e.g.
        # C:\Users\<User>\AppData\Roaming\DisplayCAL\storage\<Name>\<Name>.icm
        # Mac OS X HFS+ has a 255-character limit.
        profile_save_path = getcfg("profile.save_path")
        maxpath = 255
        # Leave headroom of 31 chars
        maxpath -= 31
        if maxpath < len(profile_save_path):
            maxpath = len(profile_save_path) + 2
        profile_path = os.path.join(profile_save_path, profile_name, profile_name)
        while len(profile_path) > maxpath:
            profile_name = profile_name[:-1]
            profile_path = os.path.join(profile_save_path, profile_name, profile_name)
        return profile_name

    def update_profile_name(self, event: None | wx.Event = None) -> None:
        """Update the profile name based on the current configuration.

        This method generates a profile name by replacing placeholders in the
        profile name text control with values from the configuration. It checks
        if the generated profile name is valid, and if not, it attempts to
        create a valid profile name by using the default profile name or the
        last used profile name. If the profile name is still invalid, it sets
        the profile name text control to a default value.

        Args:
            event (wx.Event, optional): The event that triggered this method.
        """
        profile_name = self.create_profile_name()
        if not self.check_profile_name(profile_name):
            self.profile_name_textctrl.ChangeValue(getcfg("profile.name"))
            profile_name = self.create_profile_name()
            if not self.check_profile_name(profile_name):
                self.profile_name_textctrl.ChangeValue(DEFAULTS.get("profile.name", ""))
                profile_name = self.create_profile_name()
        profile_name = make_argyll_compatible_path(profile_name)
        if profile_name != self.profile_name.GetLabel():
            setcfg("profile.name", self.profile_name_textctrl.GetValue())
            self.profile_name.SetToolTipString(profile_name)
            self.profile_name.SetLabel(profile_name.replace("&", "&&"))
            setcfg("profile.name.expanded", profile_name)

    def check_profile_name(self, profile_name: None | str = None) -> bool:
        r"""Check if the profile name is valid.

        A valid profile name must not contain any of the following characters:
        \ / : ; * ? " < > | and must not start with a dash (-).
        Additionally, it must not end with a combination of trailing spaces and
        dots, as Windows silently strips these characters.

        Args:
            profile_name (None | str, optional): The profile name to check. If
                None, it uses the value from the profile name text control.

        Returns:
            bool: True if the profile name is valid, False otherwise.
        """
        if profile_name is None:
            profile_name = self.profile_name_textctrl.GetValue()
        return (
            re.match(r"^[^\\/:;*?\"<>|]+$", profile_name)
            and not profile_name.startswith("-")
            # Windows silently strips any combination of trailing spaces and dots
            and profile_name == profile_name.rstrip(" .")
        )

    def get_ambient(self) -> None | str:
        """Return the ambient light adjustment value as a string.

        If the ambient light adjustment checkbox is checked, return the value
        from the ambient light adjustment text control, stripped of leading and
        trailing zeros. If the checkbox is not checked, it returns None.

        Returns:
            None | str: The ambient light adjustment value as a string if the
                checkbox is checked, otherwise None.
        """
        if self.ambient_viewcond_adjust_cb.GetValue():
            return str(stripzeros(self.ambient_viewcond_adjust_textctrl.GetValue()))
        return None

    def get_argyll_data_files(
        self,
        scope: str,
        wildcard: str,
        include_lastmod: bool = False,
    ) -> list[str | tuple[str, float]]:
        """Get paths of Argyll data files.

        Args:
            scope (str): A string containing "l" for local system and/or "u"
                for user.
            wildcard (str): A wildcard pattern to match files.
            include_lastmod (bool): If True, include the last modification time
                of the files.

        Returns:
            list[str | tuple[str, float]]: A list of file paths or tuples of
                (file path, last modification time).
        """
        data_files = []
        if sys.platform != "darwin":
            if "l" in scope:
                for commonappdata in config.COMMONAPPDATA:
                    data_files += safe_glob(
                        os.path.join(commonappdata, "color", wildcard)
                    )
                    data_files += safe_glob(
                        os.path.join(commonappdata, "ArgyllCMS", wildcard)
                    )
            if "u" in scope:
                data_files += safe_glob(os.path.join(config.APPDATA, "color", wildcard))
        else:
            if "l" in scope:
                data_files += safe_glob(os.path.join(config.LIBRARY, "color", wildcard))
                data_files += safe_glob(
                    os.path.join(config.LIBRARY, "ArgyllCMS", wildcard)
                )
                if [1, 9] <= self.worker.argyll_version <= [1, 9, 1]:
                    # Argyll CMS 1.9 and 1.9.1 use *nix locations due to a
                    # configuration problem
                    data_files += safe_glob(
                        os.path.join("/usr/local/share", "ArgyllCMS", wildcard)
                    )
            if "u" in scope:
                data_files += safe_glob(
                    os.path.join(config.LIBRARY_HOME, "color", wildcard)
                )
                if [1, 9] <= self.worker.argyll_version <= [1, 9, 1]:
                    # Argyll CMS 1.9 and 1.9.1 use *nix locations due to a
                    # configuration problem
                    data_files += safe_glob(
                        os.path.join(
                            config.HOME, ".local", "share", "ArgyllCMS", wildcard
                        )
                    )
        if "u" in scope:
            data_files += safe_glob(os.path.join(config.APPDATA, "ArgyllCMS", wildcard))
        filenames = list(data_files)
        data_files = []
        mapping = {}
        for filename in filenames:
            basename = os.path.basename(filename)
            if (
                basename not in mapping
                or os.path.basename(os.path.dirname(filename)) == "ArgyllCMS"
            ):
                # Prefer files with same basename in 'ArgyllCMS' folder over
                # 'color' folder
                mapping[basename] = filename

        for filename in mapping.values():
            if include_lastmod:
                try:
                    lastmod = os.stat(filename).st_mtime
                except OSError:
                    lastmod = -1
                data_files.append((filename, lastmod))
            else:
                data_files.append(filename)
        return data_files

    def get_instrument_type(self) -> str:
        """Return the instrument type as a string.

        This method checks the instrument features to determine if it is a
        colorimeter or a spectrometer.

        Returns:
            str: The instrument type, which can be either "color" for a
                colorimeter or "spect" for a spectrometer.
        """
        # Return the instrument type, "color" (colorimeter) or "spect"
        # (spectrometer)
        spect = self.worker.get_instrument_features().get("spectral", False)
        return "spect" if spect else "color"

    def get_measurement_mode(self) -> str:
        """Return the measurement mode as string.

        Examples:
            Argyll options -V -H (adaptive highres mode)
            Returned string 'VH'

            Argyll option -yl
            Returned string 'l'

            Argyll options -p -H (projector highres mode)
            Returned string 'pH'


        Returns:
            str: The measurement mode as a string, which can be one of the
                following:
                - "c" for refresh mode
                - "l" for LCD mode
                - "p" for projector mode
                - "V" for adaptive mode
                - "H" for high resolution mode
        """
        return self.measurement_modes_ab.get(self.get_instrument_type(), {}).get(
            self.measurement_mode_ctrl.GetSelection()
        )

    def get_profile_type(self) -> str:
        """Return the profile type as a string.

        Returns:
            str: The profile type as a string, which can be one of the following:
                - "G" for 1xGamma+MTX
                - "g" for 3xGamma+MTX
                - "l" for LabLUT
                - "S" for 1xCurve+MTX
                - "s" for 3xCurve+MTX
                - "X" for XYZLUT+MTX
                - "x" for XYZLUT
        """
        return self.profile_types_ab.get(
            self.profile_type_ctrl.GetSelection(), getcfg("profile.type")
        )

    def get_whitepoint(self) -> None | str:
        """Return the whitepoint as a string.

        Returns:
            None | str: The whitepoint as a string, which can be one of the
                following:
                    - None for native whitepoint
                    - A string representing the color temperature in kelvin
                    - A string representing the xy coordinates in the format
                      "x,y"
        """
        if self.whitepoint_ctrl.GetSelection() == 0:
            # Native
            return None
        if self.whitepoint_ctrl.GetSelection() == 1:
            # Color temperature in kelvin
            return str(
                stripzeros(
                    self.whitepoint_colortemp_textctrl.GetValue().replace(",", ".")
                )
            )
        if self.whitepoint_ctrl.GetSelection() == 2:
            x = self.whitepoint_x_textctrl.GetValue()
            with contextlib.suppress(ValueError):
                x = round(x, 4)
            y = self.whitepoint_y_textctrl.GetValue()
            with contextlib.suppress(ValueError):
                y = round(y, 4)
            return str(stripzeros(x)) + "," + str(stripzeros(y))
        return None

    def get_whitepoint_locus(self) -> str:
        """Return the whitepoint locus as a string.

        Returns:
            str: The whitepoint locus as a string, which can be one of the
                following:
                - "t" for the whitepoint locus of the color temperature
                - "d" for the D series whitepoint locus
                - "p" for the P series whitepoint locus
                - "c" for the C series whitepoint locus
        """
        n = self.whitepoint_colortemp_locus_ctrl.GetSelection()
        if n not in self.whitepoint_colortemp_loci_ab:
            n = 0
        return str(self.whitepoint_colortemp_loci_ab[n])

    def get_luminance(self) -> None | str:
        """Return the luminance as a string.

        Returns:
            None | str: The luminance as a string, or None if the feature is
                not enabled in the configuration.
        """
        if self.luminance_ctrl.GetSelection() == 0:
            return None
        return str(stripzeros(self.luminance_textctrl.GetValue()))

    def get_black_luminance(self) -> None | str:
        """Return the black luminance as a string.

        Returns:
            None | str: The black luminance as a string, or None if the feature
                is not enabled in the configuration.
        """
        if self.black_luminance_ctrl.GetSelection() == 0:
            return None
        return str(stripzeros(self.black_luminance_textctrl.GetValue()))

    def get_black_output_offset(self) -> str:
        """Return the black output offset as a string.

        Returns:
            str: The black output offset as a string, or "0" if the feature
                is not enabled in the configuration.
        """
        return str(Decimal(self.black_output_offset_ctrl.GetValue()) / 100)

    def get_black_point_correction(self) -> str:
        """Return the black point correction as a string.

        Returns:
            str: The black point correction as a string, or "0" if the feature
                is not enabled in the configuration.
        """
        return str(Decimal(self.black_point_correction_ctrl.GetValue()) / 100)

    def get_black_point_rate(self) -> None | str:
        """Return the black point rate as a string.

        Returns:
            None | str: The black point rate as a string, or None if the
                feature is not enabled in the configuration.
        """
        if DEFAULTS["calibration.black_point_rate.enabled"]:
            return str(self.black_point_rate_floatctrl.GetValue())
        return None

    def get_trc_type(self) -> str:
        """Return the TRC (Tone Response Curve) type as a string.

        Returns:
            str: The TRC type, which can be either "G" (absolute) or "g"
                (relative).
        """
        if self.trc_type_ctrl.GetSelection() == 1:
            return "G"
        return "g"

    def get_trc(self) -> str:
        """Return the TRC (Tone Response Curve) as a string.

        Returns:
            str: The TRC value, which can be one of the following:
                - A string representing the TRC value (e.g., "2.4", "1.8")
                - "l" for linear
                - "709" for Rec. 709
                - "240" for SMPTE 240M
                - "s" for sRGB
        """
        if self.trc_ctrl.GetSelection() in (1, 4, 7):
            return str(stripzeros(self.trc_textctrl.GetValue().replace(",", ".")))
        if self.trc_ctrl.GetSelection() == 2:
            return "l"
        if self.trc_ctrl.GetSelection() == 3:
            return "709"
        if self.trc_ctrl.GetSelection() == 5:
            return "240"
        if self.trc_ctrl.GetSelection() == 6:
            return "s"
        return ""

    def get_calibration_quality(self) -> str:
        """Return the calibration quality as a string.

        Returns:
            str: The calibration quality, which can be one of the following:
                - "u" for Ultra
                - "h" for High
                - "m" for Medium
                - "l" for Low
                - "v" for Very Low
        """
        return self.quality_ab[self.calibration_quality_ctrl.GetValue()]

    def get_profile_quality(self) -> str:
        """Return the profile quality as a string.

        Returns:
            str: The profile quality, which can be one of the following:

                - "u" for Ultra
                - "h" for High
                - "m" for Medium
                - "l" for Low
                - "v" for Very Low
        """
        return self.quality_ab[self.profile_quality_ctrl.GetValue() + 1]

    def profile_settings_changed(self) -> None:
        """Mark the profile settings as changed.

        This method is called when any profile setting is changed.
        """
        # cal = getcfg("calibration.file", False)
        # if cal:
        #     filename, ext = os.path.splitext(cal)
        #     if ext.lower() in (".icc", ".icm"):
        #         if not os.path.exists(filename + ".cal") and \
        #         not cal in self.presets:
        #             self.cal_changed()
        #             return

        if self.updatingctrls:
            return
        setcfg("settings.changed", 1)
        if self.calibration_file_ctrl.GetStringSelection().startswith("*"):
            return
        sel = self.calibration_file_ctrl.GetSelection()
        if sel > 0:
            items = self.calibration_file_ctrl.GetItems()
            items[sel] = "* " + items[sel]
            self.calibration_file_ctrl.Freeze()
            self.calibration_file_ctrl.SetItems(items)
            self.calibration_file_ctrl.SetSelection(sel)
            self.calibration_file_ctrl.Thaw()

    def testchart_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the testchart control.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        debug_print(
            "[D] testchart_ctrl_handler called for ID "
            f"{event.GetId()} {getevtobjname(event, self)} event type "
            f"{event.GetEventType()} {getevttype(event)}"
        )
        self.set_testchart(self.testcharts[self.testchart_ctrl.GetSelection()])
        wx.CallAfter(self.check_testchart_patches_amount)

    def testchart_btn_handler(self, event: wx.Event, path: None | str = None) -> None:
        """Handle the testchart button click event.

        Args:
            event (wx.Event): The event that triggered this method.
            path (None | str, optional): Path to the testchart file. If None,
                the default file dialog will be shown to select a testchart.
        """
        if path is None:
            default_dir, default_file = get_verified_path("testchart.file")
            dlg = wx.FileDialog(
                self,
                lang.getstr("dialog.set_testchart"),
                defaultDir=default_dir,
                defaultFile=default_file,
                wildcard=lang.getstr("filetype.icc_ti1_ti3")
                + "|*.icc;*.icm;*.ti1;*.ti3",
                style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
            )
            dlg.Center(wx.BOTH)
            result = dlg.ShowModal()
            if result == wx.ID_OK:
                path = dlg.GetPath()
            dlg.Destroy()
        if not path:
            return
        if not os.path.exists(path):
            InfoDialog(
                self,
                msg=lang.getstr("file.missing", path),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            return
        filename, ext = os.path.splitext(path)
        if ext.lower() in (".icc", ".icm"):
            try:
                profile = ICCProfile(path)
            except (OSError, ICCProfileInvalidError):
                InfoDialog(
                    self,
                    msg=lang.getstr("profile.invalid") + "\n" + path,
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                return
            ti3_lines = [
                line.strip()
                for line in BytesIO(
                    profile.tags.get("CIED", b"") or profile.tags.get("targ", b"")
                )
            ]
            if "CTI3" not in ti3_lines:
                InfoDialog(
                    self,
                    msg=lang.getstr("profile.no_embedded_ti3") + "\n" + path,
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                return
        self.set_testchart(path)
        writecfg()
        self.profile_settings_changed()

    def testchart_patches_amount_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the testchart patches amount control.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        auto = self.testchart_patches_amount_ctrl.GetValue()
        if event:
            setcfg("testchart.auto_optimize", auto)
            self.profile_settings_changed()
        proftype = getcfg("profile.type")
        if auto > 4:
            s = min(auto, 11) * 4 - 3
            g = s * 3 - 2
            patches_amount = get_total_patches(4, 4, s, g, auto, auto, 0) + 34
            patches_amount += 120
            if event and proftype not in ("l", "x", "X"):
                setcfg("profile.type", "x" if getcfg("3dlut.create") else "X")
        else:
            if auto == 1:
                patches_amount = 34
            elif auto == 2:
                patches_amount = 79
            elif auto == 3:
                patches_amount = 115
            else:
                patches_amount = 175
            if event:
                if auto > 1 and proftype not in ("x", "X"):
                    setcfg("profile.type", "x" if getcfg("3dlut.create") else "X")
                elif auto < 2 and proftype not in ("g", "G", "s", "S"):
                    setcfg("profile.type", "S" if getcfg("trc") else "s")
        if proftype != getcfg("profile.type"):
            self.update_profile_type_ctrl()
            # Reset profile type to previous value so the handler method will
            # recognize a change in profile type and update BPC accordingly
            setcfg("profile.type", proftype)
            self.profile_type_ctrl_handler(None)
        self.testchart_patches_amount.SetLabel(str(patches_amount))
        self.update_estimated_measurement_time("testchart")
        self.update_profile_name()

    def testchart_patch_sequence_ctrl_handler(self, event: wx.Event) -> None:
        """Handle changes to the testchart patch sequence control.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        sel = self.testchart_patch_sequence_ctrl.Selection
        setcfg(
            "testchart.patch_sequence",
            config.VALID_VALUES["testchart.patch_sequence"][sel],
        )
        self.profile_settings_changed()
        self.update_estimated_measurement_time("testchart")

    def create_testchart_btn_handler(self, event: wx.Event) -> None:
        """Create a new testchart based on the current settings.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        if not hasattr(self, "tcframe"):
            self.init_tcframe()
        elif not hasattr(self.tcframe, "ti1") or getcfg("testchart.file") not in (
            self.tcframe.ti1.filename,
            "auto",
        ):
            self.tcframe.tc_load_cfg_from_ti1(
                cfg="testchart.file", parent_set_chart_methodname="set_testchart"
            )
        setcfg("tc.show", 1)
        self.tcframe.Show()
        self.tcframe.Raise()

    def init_tcframe(self, path: None | str = None) -> None:
        """Initialize the testchart editor frame.

        Args:
            path (None | str, optional): Path to the testchart file. If None,
                the default.
        """
        self.tcframe = TestchartEditor(self, path=path)

    def set_default_test_chart(self) -> None:
        """Set the default testchart based on the current profile type and quality."""
        # print "set_default_testchart", path
        if getcfg("profile.type") in ("x", "X"):
            # XYZ cLUT
            if getcfg("testchart.auto_optimize") < 2:
                setcfg("testchart.auto_optimize", 3)
        elif getcfg("profile.type") == "l":
            # L*a*b* cLUT
            if getcfg("testchart.auto_optimize") < 5:
                setcfg("testchart.auto_optimize", 5)
        elif getcfg("testchart.auto_optimize") > 2:
            # Gamma or shaper + matrix
            setcfg("testchart.auto_optimize", 1)

    def set_default_testchart(self, alert: bool = True, force: bool = False) -> bool:
        """Set the default testchart based on the current profile type and quality.

        Args:
            alert (bool): Whether to show an alert dialog if the testchart is
                missing.
            force (bool): Whether to force setting the default testchart even
                if it is already set.

        Returns:
            None | bool: True if the testchart was set successfully, False
                otherwise. None if the testchart is already set and no
                changes were made.
        """
        path = getcfg("testchart.file")
        self.set_default_test_chart()
        if path == "auto":
            self.set_testchart(path)
            return None
        if os.path.basename(path) in self.dist_testchart_names:
            path = self.dist_testcharts[
                self.dist_testchart_names.index(os.path.basename(path))
            ]
            debug_print("[D] set_default_testchart testchart.file:", path)
            setcfg("testchart.file", path)
        if (
            not force
            and (
                lang.getstr(os.path.basename(path))
                in ["", *self.default_testchart_names]
            )
            and os.path.isfile(path)
        ):
            return None
        if not force and lang.getstr(os.path.basename(path)) in [
            "",
            *self.default_testchart_names,
        ]:
            ti1 = os.path.basename(path)
        else:
            ti1 = self.testchart_defaults[self.get_profile_type()].get(
                self.get_profile_quality(),
                self.testchart_defaults[self.get_profile_type()][None],
            )
        if ti1 == "auto":
            path = ti1
        else:
            path = get_data_path(os.path.join("ti1", ti1))
            if not path or not os.path.isfile(path):
                if alert:
                    InfoDialog(
                        self,
                        msg=lang.getstr("error.testchart.missing", ti1),
                        ok=lang.getstr("ok"),
                        bitmap=get_icon(32, "dialog-error"),
                    )
                elif VERBOSE >= 1:
                    print(lang.getstr("error.testchart.missing", ti1))
                return False
        self.set_testchart(path)
        return True

    def set_testcharts(self, path: None | str = None) -> None:
        """Set the testchart control items from the given path or use default.

        Args:
            path (None | str, optional): Path to the testchart file. If None,
                the default testchart will be used.
        """
        idx = self.testchart_ctrl.GetSelection()
        self.testchart_ctrl.Freeze()
        self.testchart_ctrl.SetItems(self.get_testchart_names(path))
        self.testchart_ctrl.SetSelection(idx)
        self.testchart_ctrl.Thaw()

    def set_testchart(
        self, path: None | str = None, update_profile_name: bool = True
    ) -> None:
        """Set the testchart to a given path or the default testchart.

        Args:
            path (str): Path to the testchart file. If None, the default
                testchart will be used.
            update_profile_name (bool): Whether to update the profile name
                after setting the testchart.
        """
        if path is None:
            path = getcfg("testchart.file")
        filename, ext = os.path.splitext(path)
        ti1_path = f"{filename}.ti1"
        if (
            ext.lower() in (".icc", ".icm")
            and getcfg("testchart.patch_sequence") != "optimize_display_response_delay"
            and os.path.isfile(ti1_path)
        ):
            # Use actual testchart file so choosing the default patch
            # sequence of optimizing response delay will actually work
            # (because the ti1 is guaranteed to be in that sequence if created
            # via targen by DisplayCAL)
            path = ti1_path
        # print "set_testchart", path
        if path == "auto" and config.get_display_name() == "Untethered":
            self._current_testchart_path = path
            if self.IsShown():
                wx.CallAfter(
                    show_result_dialog,
                    UnloggedInfo(
                        lang.getstr("testchart.auto_optimize.untethered.unsupported")
                    ),
                    self,
                )
            path = getcfg("calibration.file", False)
            if not path or path.lower().endswith(".cal"):
                path = DEFAULTS["testchart.file"]
        self.create_testchart_btn.Enable(
            path != "auto" and not getcfg("profile.update")
        )
        self.menuitem_testchart_edit.Enable(self.create_testchart_btn.Enabled)
        self.testchart_patches_amount_label.Show(path == "auto")
        self.testchart_patches_amount_ctrl.Show(path == "auto")
        if path == "auto":
            if path != getcfg("testchart.file"):
                self.profile_settings_changed()
            setcfg("testchart.file", path)
            if path not in self.testcharts:
                self.set_testcharts(path)
            self.testchart_ctrl.SetSelection(0)
            self.testchart_ctrl.SetToolTipString("")
            self.worker.options_targen = ["-d3"]
            auto = getcfg("testchart.auto_optimize") or 7
            self.testchart_patches_amount_ctrl.SetValue(auto)
            self.testchart_patches_amount_ctrl_handler(None)
            self._current_testchart_path = path
        else:
            self.set_testchart_from_path(path)
        self.check_testchart()
        if update_profile_name:
            self.update_profile_name()

    def set_testchart_from_path(self, path: str) -> None:
        """Set the testchart from a given path.

        Args:
            path (str): Path to the testchart file.
        """
        result = check_file_isfile(path)
        if isinstance(result, Exception):
            show_result_dialog(result, self)
            self.set_default_testchart(force=True)
            return
        if getattr(self, "_current_testchart_path", None) == path:
            # Nothing to do
            return
        try:
            self.load_testchart_from_file(path)
        except Exception as exception:
            error = traceback.format_exc() if DEBUG else exception
            InfoDialog(
                self,
                msg=lang.getstr("error.testchart.read", path) + "\n\n" + str(error),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            self.set_default_testchart(force=True)
        else:
            self.update_estimated_measurement_time("testchart")
            if (
                hasattr(self, "tcframe")
                and self.tcframe.IsShownOnScreen()
                and (
                    not hasattr(self.tcframe, "ti1")
                    or getcfg("testchart.file") != self.tcframe.ti1.filename
                )
            ):
                self.tcframe.tc_load_cfg_from_ti1(
                    cfg="testchart.file", parent_set_chart_methodname="set_testchart"
                )

    def load_testchart_from_file(self, path: str) -> None:
        """Load the testchart from a file and verify its contents.

        Args:
            path (str): Path to the testchart file.
        """
        ext = os.path.splitext(path)[-1]
        if ext.lower() in (".ti1", ".ti3"):
            if ext.lower() == ".ti3":
                with open(path, "rb") as f:
                    ti3_data = f.read()
                ti1 = CGATS(ti3_to_ti1(ti3_data))
            else:
                ti1 = CGATS(path)
        else:  # icc or icm profile
            profile = ICCProfile(path)
            ti1 = CGATS(
                ti3_to_ti1(profile.tags.get("CIED", "") or profile.tags.get("targ", ""))
            )
        try:
            verify_ti1_rgb_xyz(ti1)
        except CGATSError as exception:
            msg = {
                CGATSKeyError: lang.getstr(
                    "error.testchart.missing_fields",
                    (path, "RGB_R, RGB_G, RGB_B,  XYZ_X, XYZ_Y, XYZ_Z"),
                )
            }.get(
                exception.__class__,
                lang.getstr("error.testchart.invalid", path)
                + "\n"
                + lang.getstr(str(exception)),
            )

            InfoDialog(
                self,
                msg=msg,
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            self.set_default_testchart(force=True)
            return
        if path != getcfg("calibration.file", False):
            self.profile_settings_changed()
        debug_print("[D] set_testchart testchart.file:", path)
        setcfg("testchart.file", path)
        if path not in self.testcharts:
            self.set_testcharts(path)
            # The case-sensitive index could fail because of
            # case insensitive file systems, e.g. if the
            # stored filename string is
            # "C:\Users\Name\AppData\DisplayCAL\storage\MyFile"
            # but the actual filename is
            # "C:\Users\Name\AppData\DisplayCAL\storage\myfile"
            # (maybe because the user renamed the file)
        idx = index_fallback_ignorecase(self.testcharts, path)
        self.testchart_ctrl.SetSelection(idx)
        self.testchart_ctrl.SetToolTipString(path)
        if ti1.queryv1("COLOR_REP") and ti1.queryv1("COLOR_REP")[:3] == "RGB":
            self.worker.options_targen = ["-d3"]
        self.testchart_patches_amount.SetLabel(str(ti1.queryv1("NUMBER_OF_SETS")))
        self._current_testchart_path = path

    def check_testchart(self) -> None:
        """Check if the current testchart is valid and update controls accordingly."""
        if is_ccxx_testchart():
            self.set_ccxx_measurement_mode()
        else:
            self.restore_measurement_mode()
        self.update_colorimeter_correction_matrix_ctrl()
        if not self.updatingctrls:
            self.update_main_controls()

    def get_testchart_names(self, path: None | str = None) -> list[str]:
        """Get names of available testcharts.

        Args:
            path (str, optional): Path to the testchart file. If None, uses
                the value from the configuration setting "testchart.file".

        Returns:
            list: List of testchart names.
        """
        testchart_names = []
        self.testcharts = []
        if path is None:
            path = getcfg("testchart.file")
        # print "get_testchart_names", path
        if path != "auto" and os.path.exists(path):
            testchart_dir = os.path.dirname(path)
            try:
                testcharts = listdir_re(
                    testchart_dir,
                    re.escape(os.path.splitext(os.path.basename(path))[0])
                    + r"\.(?:icc|icm|ti1|ti3)$",
                )
            except Exception as exception:
                print(
                    f"Error - directory '{testchart_dir}' listing failed: {exception}"
                )
            else:
                for testchart_name in testcharts:
                    if testchart_name not in testchart_names:
                        testchart_names.append(testchart_name)
                        self.testcharts.append(
                            os.pathsep.join((testchart_name, testchart_dir))
                        )
        default_testcharts = get_data_path("ti1", r"\.(?:icc|icm|ti1|ti3)$")
        if isinstance(default_testcharts, list):
            for testchart in default_testcharts:
                testchart_dir = os.path.dirname(testchart)
                testchart_name = os.path.basename(testchart)
                if testchart_name not in testchart_names:
                    testchart_names.append(testchart_name)
                    self.testcharts.append(
                        os.pathsep.join((testchart_name, testchart_dir))
                    )
        self.testcharts = ["auto", *natsort(self.testcharts)]
        self.testchart_names = []
        for i, chart in enumerate(self.testcharts):
            chart = chart.split(os.pathsep)
            chart.reverse()
            self.testcharts[i] = os.path.join(*chart)
            testchart_name = "auto_optimized" if chart[-1] == "auto" else chart[-1]
            self.testchart_names.append(lang.getstr(testchart_name))
        return self.testchart_names

    def set_argyll_bin_handler(
        self,
        event: wx.Event,
        silent: bool = False,
        callafter: None | Callable = None,
        callafter_args: tuple = (),
    ) -> None:
        """Set Argyll CMS binary executables directory.

        Args:
            event (wx.Event): Event that triggered the handler.
            silent (bool): If True, do not show progress dialog.
            callafter (callable, optional): Function to call after setting the
                Argyll CMS binary directory.
            callafter_args (tuple, optional): Arguments to pass to the
                callable.
        """
        if (
            (getattr(self.worker, "thread", None) and self.worker.thread.is_alive())
            or not self.Shown
            or not self.Enabled
            or get_dialogs()
        ):
            wx.Bell()
            return
        if (event and set_argyll_bin(self, silent, callafter, callafter_args)) or (
            not event and check_argyll_bin()
        ):
            self.check_update_controls(
                True, callafter=callafter, callafter_args=callafter_args
            )
            if sys.platform == "win32":
                self.send_command(
                    "apply-profiles",
                    'setcfg argyll.dir "{}" force'.format(getcfg("argyll.dir")),
                )

    def check_update_controls(
        self,
        event: None | wx.Event = None,
        silent: bool = False,
        callafter: None | Callable = None,
        callafter_args: tuple = (),
    ) -> bool:
        """Update controls and menu items when displays or instruments change.

        Return True if update was needed and carried out, False otherwise.

        Args:
            event (wx.Event, optional): Event that triggered the update.
            silent (bool): If True, do not show progress dialog.
            callafter (callable, optional): Function to call after the update.
            callafter_args (tuple, optional): Arguments to pass to the callable.

        Returns:
            bool: True if update was needed and carried out, False otherwise.
        """
        if (
            self.worker.is_working()
            or not self.Shown
            or not self.Enabled
            or get_dialogs()
        ):
            return False
        argyll_bin_dir = self.worker.argyll_bin_dir
        argyll_version = list(self.worker.argyll_version)
        displays = list(self.worker.displays)
        comports = list(self.worker.instruments)
        if event:
            enumerate_ports = not isinstance(event, wx.DisplayChangedEvent)
        else:
            # Use configured value
            enumerate_ports = getcfg("enumerate_ports.auto")
        if not event and not silent:
            self.worker.enumerate_displays_and_ports(
                silent, enumerate_ports=enumerate_ports
            )
            return self.check_update_controls_consumer(
                True,
                argyll_bin_dir,
                argyll_version,
                displays,
                comports,
                event,
                callafter,
                callafter_args,
            )

        args = (
            self.check_update_controls_consumer,
            self.check_update_controls_producer,
        )
        kwargs = {
            "cargs": (
                argyll_bin_dir,
                argyll_version,
                displays,
                comports,
                event,
                callafter,
                callafter_args,
            ),
            "wkwargs": {
                "silent": True,
                "enumerate_ports": enumerate_ports,
                "displays": displays,
                "profile_loader_load_cal": isinstance(event, wx.DisplayChangedEvent),
            },
        }
        if silent:
            self.thread = delayedresult.startWorker(*args, **kwargs)
        else:
            kwargs["progress_msg"] = lang.getstr("enumerating_displays_and_comports")
            kwargs["stop_timers"] = False
            kwargs["show_remaining_time"] = False
            kwargs["fancy"] = False
            self.worker.start(*args, **kwargs)

        return None

    def check_update_controls_producer(
        self,
        silent: bool = False,
        enumerate_ports: bool = True,
        displays: None | list = None,
        profile_loader_load_cal: bool = False,
    ) -> delayedresult.DelayedResult:
        """Produce results for check_update_controls.

        Args:
            silent (bool, optional): If True, do not show progress dialog.
            enumerate_ports (bool, optional): If True, enumerate comports.
            displays (list, optional): List of detected displays.
            profile_loader_load_cal (bool, optional): If True, load calibration
                profiles when display changes are detected.

        Returns:
            delayedresult.DelayedResult: Result of the enumeration.
        """
        result = self.worker.enumerate_displays_and_ports(
            silent, enumerate_ports=enumerate_ports
        )
        if (
            sys.platform == "win32"
            and displays != self.worker.displays
            and profile_loader_load_cal
            and not util_win.calibration_management_isenabled()
        ):
            # Tell profile loader to load calibration
            self.send_command("apply-profiles", "apply-profiles display-changed")
        return result

    def check_update_controls_consumer(
        self,
        result: delayedresult.DelayedResult | Exception,
        argyll_bin_dir: str,
        argyll_version: list,
        displays: list,
        comports: list,
        event: None | wx.Event = None,
        callafter: None | Callable = None,
        callafter_args: None | tuple = None,
    ) -> bool:
        """Consumer for check_update_controls_producer.

        Args:
            result (delayedresult.DelayedResult or Exception): Result from the worker.
            argyll_bin_dir (str): Argyll CMS binary directory.
            argyll_version (list): Argyll CMS version as a list.
            displays (list): List of detected displays.
            comports (list): List of detected comports.
            event (wx.Event, optional): Event that triggered the update.
            callafter (callable, optional): Function to call after the update.
            callafter_args (tuple, optional): Arguments to pass to the callable.

        Returns:
            bool: True if update was needed and carried out, False otherwise.
        """
        result = self.process_delayed_result(result)
        if isinstance(result, Exception):
            raise result
        self.update_argyll_configuration(argyll_bin_dir, argyll_version, comports)
        if displays != self.worker.displays:
            self.update_displays(update_ccmx_items=True)
            verbose_print(lang.getstr("display_detected"))
        if comports != self.worker.instruments:
            self.update_comports()
            verbose_print(lang.getstr("comport_detected"))
            if event and not callafter:
                # Check if we should import colorimeter corrections
                # or other instrument setup
                self.check_instrument_setup()
        if displays != self.worker.displays or comports != self.worker.instruments:
            if self.IsShownOnScreen():
                self.update_menus()
            self.update_main_controls()
            returnvalue = True
        else:
            returnvalue = False
        if len(self.worker.displays):
            if getcfg("calibration.file", False):
                # Load LUT curves from last used .cal file
                self.load_cal(silent=True)
            else:
                # Load LUT curves from current display profile (if any,
                # and if it contains curves)
                self.load_display_profile_cal(None)
        callafter(*callafter_args) if callafter else None
        return returnvalue

    def process_delayed_result(
        self, result: delayedresult.DelayedResult | Exception
    ) -> delayedresult.DelayedResult | Error:
        """Process the result from a delayed operation.

        Args:
            result (delayedresult.DelayedResult | Exception): The result to
                process.
        """
        if isinstance(result, delayedresult.DelayedResult):
            try:
                result.get()
            except Exception as exception:
                if hasattr(exception, "originalTraceback"):
                    error = exception.originalTraceback
                else:
                    error = traceback.format_exc()
                result = Error(error)
        return result

    def update_argyll_configuration(
        self,
        argyll_bin_dir: str,
        argyll_version: list,
        comports: list,
    ) -> None:
        """Update Argyll CMS configuration based on detected binaries and version.

        Args:
            argyll_bin_dir (str): Path to the Argyll CMS binary directory.
            argyll_version (list): List containing the Argyll CMS version.
            comports (list): List of detected comports.
        """
        if (
            argyll_bin_dir == self.worker.argyll_bin_dir
            and argyll_version == self.worker.argyll_version
        ):
            return
        self.show_advanced_options_handler()
        self.worker.measurement_modes = {}
        self.update_measurement_modes()
        if comports == self.worker.instruments:
            self.update_colorimeter_correction_matrix_ctrl()
        self.update_black_point_rate_ctrl()
        self.update_drift_compensation_ctrls()
        self.setup_observer_ctrl()
        self.update_observer_ctrl()
        self.update_profile_type_ctrl_items()
        self.update_profile_type_ctrl()
        self.lut3d_setup_language()
        self.lut3d_init_input_profiles()
        self.lut3d_update_controls()
        self.aboutdialog_handler(None) if hasattr(
            self, "aboutdialog"
        ) and self.aboutdialog.IsShownOnScreen() else None
        self.extra_args.update_controls() if hasattr(self, "extra_args") else None
        if hasattr(self, "gamapframe"):
            visible = self.gamapframe.IsShownOnScreen()
            self.gamapframe.Close()
            self.gamapframe.Destroy()
            del self.gamapframe
            self.gamap_btn_handler(None) if visible else None
        if getattr(self, "lut3dframe", None):
            visible = self.lut3dframe.IsShownOnScreen()
            self.lut3dframe.Close()
            self.lut3dframe.Destroy()
            del self.lut3dframe
            self.lut3d_create_handler(None) if visible else None
        if getattr(self, "reportframe", None):
            visible = self.reportframe.IsShownOnScreen()
            self.reportframe.Close()
            self.reportframe.Destroy()
            del self.reportframe
            self.measurement_report_create_handler(None) if visible else None
        if hasattr(self, "tcframe"):
            visible = self.tcframe.IsShownOnScreen()
            self.tcframe.tc_close_handler()
            self.tcframe.Destroy()
            del self.tcframe
            self.create_testchart_btn_handler(None) if visible else None

    def check_instrument_setup(
        self, callafter: None | Callable = None, callafter_args: tuple = ()
    ) -> None:
        """Check if colorimeter corrections should be imported.

        Or do other instrument specific setup.

        Args:
            callafter (callable, optional): Function to call after the setup check.
            callafter_args (tuple, optional): Arguments to pass to the callable.
        """
        # Check if we should import colorimeter corrections
        # or do other instrument specific setup
        if (
            self.worker.is_working()
            or not self.Shown
            or not self.Enabled
            or get_dialogs()
        ):
            return
        if getcfg("colorimeter_correction_matrix_file") in ("AUTO:", ""):
            # Check for applicable corrections
            ccmx_instruments = iter(self.ccmx_instruments.values())
            i1d3 = (
                "i1 DisplayPro, ColorMunki Display" in self.worker.instruments
                and "" not in ccmx_instruments
            )
            icd = (
                ("DTP94" in self.worker.instruments and "DTP94" not in ccmx_instruments)
                or (
                    "i1 Display 2" in self.worker.instruments
                    and "i1 Display 2" not in ccmx_instruments
                )
                or (
                    "Spyder2" in self.worker.instruments
                    and "Spyder2" not in ccmx_instruments
                )
                or (
                    "Spyder3" in self.worker.instruments
                    and "Spyder3" not in ccmx_instruments
                )
            )
        else:
            # Already using a suitable correction
            i1d3 = False
            icd = False
        spyd2 = (
            "Spyder2" in self.worker.instruments
            and not self.worker.spyder2_firmware_exists()
        )
        spyd4 = (
            "Spyder4" in self.worker.instruments or "Spyder5" in self.worker.instruments
        ) and not self.worker.spyder4_cal_exists()
        if spyd2:
            spyd2 = self.enable_spyder2_handler(
                True,
                i1d3 or icd or spyd4,
                callafter=callafter,
                callafter_args=callafter_args,
            )
        result = spyd2
        if not spyd2 and (i1d3 or icd or spyd4):
            result = self.import_colorimeter_corrections_handler(
                True, callafter=callafter, callafter_args=callafter_args
            )
        if not result and callafter:
            callafter(*callafter_args)

    def load_cal_handler(
        self,
        event: wx.Event,
        path: None | str = None,
        update_profile_name: bool = True,
        silent: bool = False,
        load_vcgt: bool = True,
    ) -> None:
        """Load settings and calibration.

        Args:
            event (wx.Event): The event that triggered this method.
            path (str, optional): Path to the calibration file. If None, a file
                dialog will be shown to select a file. Defaults to None.
            update_profile_name (bool, optional): Whether to update the profile
                name after loading the calibration. Defaults to True.
            silent (bool, optional): If True, suppresses dialog messages.
                Defaults to False.
            load_vcgt (bool, optional): If True, loads the VCGT from the
                calibration file.
        """
        if not check_set_argyll_bin():
            return

        if path is None:
            path = self.get_calibration_file_path()

        if not path:
            return

        if getcfg("settings.changed"):
            return

        if not os.path.exists(path):
            self.remove_missing_calibration(path)
            return

        is_preset = path in self.presets
        basename = os.path.basename(path)
        is_3dlut_preset = is_preset and basename.startswith("video_")

        ext = os.path.splitext(path)[-1]
        if ext.lower() in COMPRESSED_FILE_EXTENSIONS:
            self.import_session_archive(path)
            return

        profile, ti3_lines = self.parse_calibration_file(path)
        if profile is None or ti3_lines is None:
            return
        setcfg("last_cal_or_icc_path", path)
        update_ccmx_items = True
        set_size = True
        display_match = False
        display_changed = False
        instrument_id = None
        instrument_match = False
        if ext.lower() in ICCPROFILE_FILE_EXTENSIONS:
            setcfg("last_icc_path", path)
            if path not in self.presets:
                setcfg("3dlut.output.profile", path)
                setcfg("measurement_report.output_profile", path)
            # Disable 3D LUT tab when switching from madVR / Resolve
            setcfg("3dlut.tab.enable", 0)
            setcfg("3dlut.tab.enable.backup", 0)
            (options_dispcal, options_colprof) = get_options_from_profile(profile)
            # Get and set the display
            # First try to find the correct display by comparing
            # the model (if present)
            display_name = profile.getDeviceModelDescription()
            # Second try to find the correct display by comparing
            # the EDID hash (if present)
            profile_tags_meta = profile.tags.get("meta", {})
            edid_md5 = profile_tags_meta.get("EDID_md5", {}).get("value")
            if display_name or edid_md5:
                display_name_indexes = []
                edid_md5_indexes = []
                for i, edid in enumerate(self.worker.display_edid):
                    if display_name in (
                        edid.get(b"monitor_name", False),
                        self.worker.display_names[i],
                    ):
                        display_name_indexes.append(i)
                    if edid_md5 == edid.get(b"hash", False):
                        edid_md5_indexes.append(i)

                if len(display_name_indexes) == 1:
                    display_index = display_name_indexes[0]
                    print(
                        "Found display device matching model description at "
                        f"index #{display_index:.0f}"
                    )
                elif len(edid_md5_indexes) == 1:
                    display_index = edid_md5_indexes[0]
                    print(
                        "Found display device matching EDID MD5 at index "
                        f"#{display_index:.0f}"
                    )
                else:
                    # We got several matches. As we can't be sure which
                    # is the right one, do nothing.
                    display_index = None

                if display_index is not None:
                    # Found it
                    display_match = True
                    if config.get_display_name(None, False) != config.get_display_name(
                        display_index, False
                    ):
                        # Only need to update if currently selected display
                        # does not match found one
                        setcfg("display.number", display_index + 1)
                        self.get_set_display()
                        display_changed = True

                    if (
                        config.is_virtual_display()
                        or config.get_display_name() == "SII REPEATER"
                    ):
                        # Don't disable 3D LUT tab when switching from
                        # madVR / Resolve / eeColor
                        setcfg("3dlut.tab.enable", 1)
                        setcfg("3dlut.tab.enable.backup", 1)
            # Get and set the instrument
            instrument_id = (
                profile.tags.get("meta", {}).get("MEASUREMENT_device", {}).get("value")
            )
            if instrument_id:
                for i, instrument in enumerate(self.worker.instruments):
                    if instrument.lower() == instrument_id:
                        # Found it
                        instrument_match = True
                        if self.worker.get_instrument_name().lower() == instrument_id:
                            # No need to update anything
                            break
                        setcfg("comport.number", i + 1)
                        self.update_comports()
                        # No need to update ccmx items in update_controls,
                        # as comport_ctrl_handler took care of it
                        update_ccmx_items = False
                        # comport_ctrl_handler already called set_size
                        set_size = False
                        break
        else:
            try:
                options_dispcal, options_colprof = get_options_from_cal(path)
            except (OSError, CGATSError):
                InfoDialog(
                    self,
                    msg="{}\n{}".format(lang.getstr("calibration.file.invalid"), path),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                return
        black_point_correction = False
        if options_dispcal or options_colprof:
            debug_print("[D] options_dispcal:", options_dispcal)
            debug_print("[D] options_colprof:", options_colprof)
            ccxxsetting = getcfg("colorimeter_correction_matrix_file").split(":", 1)[0]
            ccmx = None
            # Check if TRC was set
            trc = False
            if options_dispcal:
                for option in options_dispcal:
                    if option[0:1] in ("g", "G"):
                        trc = True
            # Restore defaults
            self.restore_defaults_handler(
                include=(
                    "calibration",
                    "drift_compensation",
                    "measure.darken_background",
                    "measure.override_min_display_update_delay_ms",
                    "measure.min_display_update_delay_ms",
                    "measure.override_display_settle_time_mult",
                    "measure.display_settle_time_mult",
                    "observer",
                    "patterngenerator.ffp_insertion",
                    "trc",
                    "whitepoint",
                ),
                exclude=(
                    "calibration.black_point_correction_choice.show",
                    "calibration.update",
                    "calibration.use_video_lut",
                    "measure.darken_background.show_warning",
                    "patterngenerator.ffp_insertion.interval",
                    "patterngenerator.ffp_insertion.duration",
                    "patterngenerator.ffp_insertion.level",
                    "trc.should_use_viewcond_adjust.show_msg",
                ),
                override={"trc": ""} if not trc else None,
            )
            # Parse options
            if options_dispcal:
                self.worker.options_dispcal = [f"-{arg}" for arg in options_dispcal]
                for option in options_dispcal:
                    dispcal_options_to_config_map = {
                        "d": (
                            self.set_display_number_config_with_option,
                            (option, display_match, display_changed),
                            ("display_match", "display_changed"),
                        ),
                        "m": (
                            self.set_interactive_display_adjustment_config_with_option,
                            (option,),
                            (),
                        ),
                        # "o": ("profile.update", 1),
                        # "u": ("calibration.update", 1),
                        "q": (
                            self.set_calibration_quality_config_with_option,
                            (option,),
                            (),
                        ),
                        "y": (
                            self.set_measurement_mode_config_with_option,
                            (option,),
                            (),
                        ),
                        "t": (
                            self.set_whitepoint_temperature_config_with_option,
                            (option,),
                            (),
                        ),
                        "T": (
                            self.set_whitepoint_temperature_config_with_option,
                            (option,),
                            (),
                        ),
                        "W": (self.set_whitepoint_config_with_option, (option,), ()),
                        "b": (
                            self.set_calibration_luminance_config_with_option,
                            (option,),
                            (),
                        ),
                        "g": (
                            self.set_tone_response_curve_config_with_option,
                            (option,),
                            (),
                        ),
                        "G": (
                            self.set_tone_response_curve_config_with_option,
                            (option,),
                            (),
                        ),
                        "f": (
                            self.set_calibration_black_output_offset_config_with_option,
                            (option,),
                            (),
                        ),
                        "a": (
                            self.set_ambient_view_condition_adjustment_config_with_option,
                            (option,),
                            (),
                        ),
                        "k": (
                            self.set_black_point_correction_config_with_option,
                            (option, black_point_correction),
                            ("black_point_correction",),
                        ),
                        "A": (
                            self.set_calibration_black_point_rate_config_with_option,
                            (option,),
                            (),
                        ),
                        "B": (
                            self.set_calibration_black_luminance_config_with_option,
                            (option,),
                            (),
                        ),
                        # "p": (self.set_measureframe_config_with_option, (option,), ()),  # noqa: E501
                        "P": (self.set_measureframe_config_with_option, (option,), ()),
                        "V": (
                            self.set_measurement_mode_adaptive_config_with_option,
                            (1,),
                            (),
                        ),
                        "YA": (
                            self.set_measurement_mode_adaptive_config_with_option,
                            (0,),
                            (),
                        ),
                        "H": (
                            self.set_measurement_mode_highres_config_with_option,
                            (1,),
                            (),
                        ),
                        "p": (
                            self.set_measurement_mode_projector_config_with_option,
                            (option,),
                            (),
                        ),
                        "F": (
                            self.set_measure_darken_background_config_with_option,
                            (1,),
                            (),
                        ),
                        "X": (
                            self.set_ccss_config_with_option,
                            (option, path),
                            ("ccmx", "update_ccmx_items"),
                        ),
                        "I": (
                            self.set_drift_compensation_config_with_option,
                            (option,),
                            (),
                        ),
                        "Q": (
                            self.set_tristimulus_observer_config_with_option,
                            (option,),
                            ("update_ccmx_items"),
                        ),
                        "E": (self.set_video_levels_config_with_option, (), ()),
                    }
                    config_option_data = dispcal_options_to_config_map.get(
                        option[0:1], dispcal_options_to_config_map.get(option[0:2])
                    )
                    if config_option_data is None:
                        debug_print(
                            f"Couldn't match data for dispcal options: {option[0:1]}"
                        )
                        continue
                    # we must have a setter function
                    func = config_option_data[0]
                    args = config_option_data[1]
                    local_var_names = config_option_data[2]
                    return_values = func(*args)
                    if return_values is not None:
                        # we have some return values, set locals
                        for local_var_name, return_value in zip(
                            local_var_names, return_values
                        ):
                            locals()[local_var_name] = return_value

                if trc and not black_point_correction:
                    setcfg("calibration.black_point_correction.auto", 1)
            self.update_whitepoint_config_from_temperature()
            update_ccmx_items = self.update_ccmx_items_from_path(
                ccmx, path, ccxxsetting, update_ccmx_items
            )

            if options_colprof:
                # restore defaults
                self.restore_defaults_handler(
                    include=(
                        "profile",
                        "gamap_",
                        "3dlut.create",
                        "3dlut.output.profile.apply_cal",
                        "3dlut.trc",
                        "testchart.auto_optimize",
                        "testchart.patch_sequence",
                    ),
                    exclude=(
                        "3dlut.tab.enable.backup",
                        "profile.update",
                        "profile.name",
                        "gamap_default_intent",
                    ),
                )
                for option in options_colprof:
                    colprof_options_to_config_map = {
                        "q": (
                            self.set_profile_quality_config_with_option,
                            (option,),
                            (),
                        ),
                        "b": (
                            self.set_profile_quality_b2a_config_with_option,
                            (option,),
                            (),
                        ),
                        "a": (
                            self.set_profile_black_point_compenstation_config_with_option,
                            (option, is_preset, is_3dlut_preset),
                            (),
                        ),
                        "s": (self.set_gamap_profile_config_with_option, (option,), ()),
                        "S": (self.set_gamap_profile_config_with_option, (option,), ()),
                        "c": (
                            self.set_gamap_src_viewcond_config_with_option,
                            (option,),
                            (),
                        ),
                        "d": (
                            self.set_gamap_out_viewcond_config_with_option,
                            (option,),
                            (),
                        ),
                        "t": (
                            self.set_gamap_perceptual_intent_config_with_option,
                            (option,),
                            (),
                        ),
                        "T": (
                            self.set_gamap_saturation_intent_config_with_option,
                            (option,),
                            (),
                        ),
                    }
                    config_option_data = colprof_options_to_config_map.get(
                        option[0:1], colprof_options_to_config_map.get(option[0:2])
                    )
                    if config_option_data is None:
                        debug_print(
                            f"Couldn't match data for colprof options: {option[0:1]}"
                        )
                        continue
                    # we must have a setter function
                    func = config_option_data[0]
                    args = config_option_data[1]
                    local_var_names = config_option_data[2]
                    return_values = func(*args)
                    if return_values is not None:
                        # we have some return values, set locals
                        for local_var_name, return_value in zip(
                            local_var_names, return_values
                        ):
                            locals()[local_var_name] = return_value

            setcfg("calibration.file", path)
            if "CTI3" in ti3_lines:
                debug_print("[D] load_cal_handler testchart.file:", path)
                setcfg("testchart.file", path)
            if 'USE_BLACK_POINT_COMPENSATION "YES"' in ti3_lines:
                setcfg("profile.black_point_compensation", 1)
            elif 'USE_BLACK_POINT_COMPENSATION "NO"' in ti3_lines and (
                sys.platform != "darwin" or not is_preset or is_3dlut_preset
            ):
                # Only disable BPC if not OS X, or if a preset,
                # or if a 3D LUT preset
                setcfg("profile.black_point_compensation", 0)
            if 'HIRES_B2A "YES"' in ti3_lines:
                setcfg("profile.b2a.hires", 1)
            elif 'HIRES_B2A "NO"' in ti3_lines:
                setcfg("profile.b2a.hires", 0)
            if 'SMOOTH_B2A "YES"' in ti3_lines:
                if 'HIRES_B2A "NO"' not in ti3_lines:
                    setcfg("profile.b2a.hires", 1)
                setcfg("profile.b2a.hires.smooth", 1)
            elif 'SMOOTH_B2A "NO"' in ti3_lines:
                if 'HIRES_B2A "YES"' not in ti3_lines:
                    setcfg("profile.b2a.hires", 0)
                setcfg("profile.b2a.hires.smooth", 0)
            simset = False  # Only HDR 3D LUTs will have this set
            if "BEGIN_DATA_FORMAT" in ti3_lines:
                cfgend = ti3_lines.index(b"BEGIN_DATA_FORMAT")
                cfgpart = CGATS(b"\n".join(ti3_lines[:cfgend]))
                lut3d_trc_set = False
                config_mapper = {
                    "SMOOTH_B2A_SIZE": "profile.b2a.hires.size",
                    "HIRES_B2A_SIZE": "profile.b2a.hires.size",
                    # NOTE that profile black point
                    # correction is not the same
                    # as calibration black point
                    # correction!
                    # See Worker.create_profile in
                    # worker.py
                    "BLACK_POINT_CORRECTION": "profile.black_point_correction",
                    "MIN_DISPLAY_UPDATE_DELAY_MS": (
                        "measure.min_display_update_delay_ms"
                    ),
                    "DISPLAY_SETTLE_TIME_MULT": "measure.display_settle_time_mult",
                    "FFP_INSERTION_INTERVAL": "patterngenerator.ffp_insertion.interval",
                    "FFP_INSERTION_DURATION": "patterngenerator.ffp_insertion.duration",
                    "FFP_INSERTION_LEVEL": "patterngenerator.ffp_insertion.level",
                    "AUTO_OPTIMIZE": "testchart.auto_optimize",
                    "PATCH_SEQUENCE": "testchart.patch_sequence",
                    "3DLUT_SOURCE_PROFILE": "3dlut.input.profile",
                    "3DLUT_TRC": "3dlut.trc",
                    "3DLUT_HDR_PEAK_LUMINANCE": "3dlut.hdr_peak_luminance",
                    "3DLUT_HDR_SAT": "3dlut.hdr_sat",
                    "3DLUT_HDR_HUE": "3dlut.hdr_hue",
                    "3DLUT_HDR_DISPLAY": "3dlut.hdr_display",
                    # MaxCLL is no longer used,
                    # map to mastering display max light level (MaxMLL)
                    "3DLUT_HDR_MAXCLL": "3dlut.hdr_maxmll",
                    "3DLUT_HDR_MAXMLL": "3dlut.hdr_maxmll",
                    "3DLUT_HDR_MAXMLL_ALT_CLIP": "3dlut.hdr_maxmll_alt_clip",
                    "3DLUT_HDR_MINMLL": "3dlut.hdr_minmll",
                    "3DLUT_HDR_AMBIENT_LUMINANCE": "3dlut.hdr_ambient_luminance",
                    "3DLUT_GAMMA": "3dlut.trc_gamma",
                    "3DLUT_DEGREE_OF_BLACK_OUTPUT_OFFSET": "3dlut.trc_output_offset",
                    "3DLUT_INPUT_ENCODING": "3dlut.encoding.input",
                    "3DLUT_OUTPUT_ENCODING": "3dlut.encoding.output",
                    "3DLUT_GAMUT_MAPPING_MODE": "3dlut.gamap.use_b2a",
                    "3DLUT_RENDERING_INTENT": "3dlut.rendering_intent",
                    "3DLUT_FORMAT": "3dlut.format",
                    "3DLUT_SIZE": "3dlut.size",
                    "3DLUT_INPUT_BITDEPTH": "3dlut.bitdepth.input",
                    "3DLUT_OUTPUT_BITDEPTH": "3dlut.bitdepth.output",
                    "3DLUT_APPLY_CAL": "3dlut.output.profile.apply_cal",
                    "SIMULATION_PROFILE": "measurement_report.simulation_profile",
                }
                for keyword in config_mapper:
                    cfgname = config_mapper[keyword]
                    cfgvalue = cfgpart.queryv1(keyword)
                    if keyword in (
                        "MIN_DISPLAY_UPDATE_DELAY_MS",
                        "DISPLAY_SETTLE_TIME_MULT",
                    ):
                        backup = getcfg(
                            f"measure.override_{keyword.lower()}.backup", False
                        )
                        if (
                            cfgvalue is not None
                            and display_match
                            and (instrument_match or not instrument_id)
                        ):
                            # Only set display update delay if a matching
                            # display/instrument stored in profile meta
                            # tag or no instrument ID (i.e. a preset)
                            if backup is None:
                                setcfg(
                                    f"measure.override_{keyword.lower()}.backup",
                                    getcfg(f"measure.override_{keyword.lower()}"),
                                )
                                setcfg(
                                    f"measure.{keyword.lower()}.backup",
                                    getcfg(f"measure.{keyword.lower()}"),
                                )
                            setcfg(f"measure.override_{keyword.lower()}", 1)
                        elif backup is not None:
                            setcfg(f"measure.override_{keyword.lower()}", backup)
                            cfgvalue = getcfg(f"measure.{keyword.lower()}.backup")
                            setcfg(f"measure.override_{keyword.lower()}.backup", None)
                            setcfg(f"measure.{keyword.lower()}.backup", None)
                    elif cfgvalue is not None:
                        if keyword == "AUTO_OPTIMIZE" and cfgvalue:
                            setcfg("testchart.file", "auto")
                            if (
                                is_preset
                                and not is_3dlut_preset
                                and sys.platform == "darwin"
                            ):
                                # Profile type forced to matrix due to
                                # OS X bugs with cLUT profiles. Set
                                # smallest testchart.
                                cfgvalue = 1
                        elif keyword == "PATCH_SEQUENCE":
                            cfgvalue = cfgvalue.lower().replace("_rgb_", "_RGB_")
                        elif keyword == "3DLUT_GAMMA":
                            try:
                                cfgvalue = float(cfgvalue)
                            except Exception:
                                pass
                            else:
                                if cfgvalue < 0:
                                    gamma_type = "B"
                                    cfgvalue = abs(cfgvalue)
                                else:
                                    gamma_type = "b"
                                setcfg("3dlut.trc_gamma_type", gamma_type)
                                # Sync measurement report settings
                                setcfg("measurement_report.trc_gamma_type", gamma_type)
                                setcfg("measurement_report.apply_black_offset", 0)
                                setcfg("measurement_report.apply_trc", 1)
                        elif keyword == "3DLUT_GAMUT_MAPPING_MODE":
                            cfgvalue = 0 if cfgvalue == "G" else 1
                        elif keyword in (
                            "FFP_INSERTION_INTERVAL",
                            "FFP_INSERTION_DURATION",
                            "FFP_INSERTION_LEVEL",
                        ):
                            setcfg("patterngenerator.ffp_insertion", 1)
                        if keyword.startswith("3DLUT"):
                            setcfg("3dlut.create", 1)
                            setcfg("3dlut.tab.enable", 1)
                            setcfg("3dlut.tab.enable.backup", 1)
                    if cfgvalue is not None:
                        cfgvalue = str(cfgvalue)
                        if cfgname.endswith("profile") and (
                            not os.path.isabs(cfgvalue) or not os.path.isfile(cfgvalue)
                        ):
                            if os.path.basename(os.path.dirname(cfgvalue)) == "ref":
                                # Fall back to ref file if not absolute
                                # path or not found
                                cfgvalue = (
                                    get_data_path("ref/" + os.path.basename(cfgvalue))
                                    or cfgvalue
                                )
                            elif not os.path.dirname(cfgvalue):
                                # Use profile dir
                                cfgvalue = os.path.join(os.path.dirname(path), cfgvalue)
                        setcfg(cfgname, cfgvalue)
                        if keyword == "SIMULATION_PROFILE":
                            # Only HDR 3D LUTs will have this set
                            simset = True
                        # Sync measurement report settings
                        if cfgname == "3dlut.input.profile":
                            if not simset:
                                setcfg(
                                    "measurement_report.simulation_profile",
                                    cfgvalue,
                                )
                            setcfg("measurement_report.use_simulation_profile", 1)
                            setcfg(
                                "measurement_report.use_simulation_profile_as_output",
                                1,
                            )
                        elif cfgname in (
                            "3dlut.trc_gamma",
                            "3dlut.trc_output_offset",
                        ):
                            cfgname = cfgname.replace("3dlut", "measurement_report")
                            setcfg(cfgname, cfgvalue)
                        elif cfgname == "3dlut.format":
                            if cfgvalue == "madVR" and not simset:
                                setcfg("3dlut.enable", 1)
                            if (
                                cfgvalue == "madVR" and not simset
                            ) or cfgvalue == "eeColor":
                                setcfg("measurement_report.use_devlink_profile", 0)
                        elif cfgname == "3dlut.trc":
                            lut3d_trc_set = True
                # Content color space (currently only used for HDR)
                for color in ("white", "red", "green", "blue"):
                    for coord in "xy":
                        keyword = (
                            f"3DLUT_CONTENT_COLORSPACE_{color.upper()}_{coord.upper()}"
                        )
                        cfgvalue = cfgpart.queryv1(keyword)
                        if cfgvalue is None:
                            continue
                        cfgvalue = str(cfgvalue)
                        with contextlib.suppress(ValueError):
                            cfgvalue = round(float(cfgvalue), 4)
                        setcfg(
                            f"3dlut.content.colorspace.{color}.{coord}",
                            cfgvalue,
                        )
                # Make sure 3D LUT TRC enumeration matches parameters for
                # older profiles not containing 3DLUT_TRC
                if not lut3d_trc_set:
                    if (
                        getcfg("3dlut.trc_gamma_type") == "B"
                        and getcfg("3dlut.trc_output_offset") == 0
                        and getcfg("3dlut.trc_gamma") == 2.4
                    ):
                        setcfg("3dlut.trc", "bt1886")  # BT.1886
                    elif (
                        getcfg("3dlut.trc_gamma_type") == "b"
                        and getcfg("3dlut.trc_output_offset") == 1
                        and getcfg("3dlut.trc_gamma") == 2.2
                    ):
                        setcfg("3dlut.trc", "gamma2.2")  # Pure power gamma 2.2
                    else:
                        setcfg("3dlut.trc", "customgamma")  # Custom
            if not display_changed:
                self.update_menus()
                if not update_ccmx_items:
                    self.update_estimated_measurement_time("cal")
            self.lut3d_set_path()
            if config.get_display_name() == "Resolve":
                setcfg("3dlut.enable", 0)
                setcfg("measurement_report.use_devlink_profile", 1)
            elif config.get_display_name(None, True) == "Prisma":
                setcfg("3dlut.enable", 1)
                setcfg("measurement_report.use_devlink_profile", 0)
            if getcfg("3dlut.format") == "madVR" and simset:
                # Currently, it is not possible to verify HDR 3D LUTs
                # through madVR in another way
                setcfg("3dlut.enable", 0)
                setcfg("measurement_report.use_devlink_profile", 1)
            self.update_controls(
                update_profile_name=update_profile_name,
                update_ccmx_items=update_ccmx_items,
            )
            if set_size:
                self.set_size(True)
            writecfg()

            if ext.lower() in ICCPROFILE_FILE_EXTENSIONS:
                if load_vcgt:
                    # load calibration into lut
                    self.load_cal(silent=True)
                if options_dispcal and options_colprof:
                    return
                if options_dispcal:
                    msg = lang.getstr("settings_loaded.cal_and_lut")
                else:
                    msg = lang.getstr("settings_loaded.profile_and_lut")
            elif options_dispcal and options_colprof:
                msg = lang.getstr("settings_loaded.cal_and_profile")
            elif options_dispcal:
                if not load_vcgt:
                    msg = lang.getstr("settings_loaded.cal")
                else:
                    # load calibration into lut
                    self.load_cal(silent=True)
                    msg = lang.getstr("settings_loaded.cal_and_lut")
            else:
                msg = lang.getstr("settings_loaded.profile")

            # if not silent:
            # InfoDialog(self, msg=msg + "\n" + path, ok=lang.getstr("ok"),
            # bitmap=get_icon(32, "dialog-information"))
            return

        if ext.lower() in ICCPROFILE_FILE_EXTENSIONS:
            sel = self.calibration_file_ctrl.GetSelection()
            if len(self.recent_cals) > sel and self.recent_cals[sel] == path:
                self.recent_cals.remove(self.recent_cals[sel])
                self.calibration_file_ctrl.Delete(sel)
                cal = getcfg("calibration.file", False) or ""
                if cal not in self.recent_cals:
                    self.recent_cals.append(cal)
                # The case-sensitive index could fail because of
                # case insensitive file systems, e.g. if the
                # stored filename string is
                # "C:\Users\Name\AppData\DisplayCAL\storage\MyFile"
                # but the actual filename is
                # "C:\Users\Name\AppData\DisplayCAL\storage\myfile"
                # (maybe because the user renamed the file)
                idx = index_fallback_ignorecase(self.recent_cals, cal)
                self.calibration_file_ctrl.SetSelection(idx)
            if not silent:
                InfoDialog(
                    self,
                    msg=lang.getstr("no_settings") + "\n" + path,
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
            return

        # Old .cal file without ARGYLL_DISPCAL_ARGS section

        setcfg("last_cal_path", path)

        # Restore defaults
        self.restore_defaults_handler(
            include=(
                "calibration",
                "profile.update",
                "measure.override_min_display_update_delay_ms",
                "measure.min_display_update_delay_ms",
                "measure.override_display_settle_time_mult",
                "measure.display_settle_time_mult",
                "trc",
                "whitepoint",
            ),
            exclude=(
                "calibration.black_point_correction_choice.show",
                "calibration.update",
                "trc.should_use_viewcond_adjust.show_msg",
            ),
        )

        self.worker.options_dispcal = []
        settings = []
        for line in ti3_lines:
            line = line.strip().split(b" ", 1)
            if len(line) > 1:
                value = line[1][1:-1]  # strip quotes
                if line[0] == "DEVICE_CLASS":
                    if value != "DISPLAY":
                        InfoDialog(
                            self,
                            msg=lang.getstr("calibration.file.invalid") + "\n" + path,
                            ok=lang.getstr("ok"),
                            bitmap=get_icon(32, "dialog-error"),
                        )
                        return
                elif line[0] == "DEVICE_TYPE":
                    measurement_mode = value.lower()[0]
                    if measurement_mode in ("c", "l"):
                        setcfg("measurement_mode", measurement_mode)
                        self.worker.options_dispcal.append("-y" + measurement_mode)
                elif line[0] == "NATIVE_TARGET_WHITE":
                    setcfg("whitepoint.colortemp", None)
                    setcfg("whitepoint.x", None)
                    setcfg("whitepoint.y", None)
                    setcfg("3dlut.whitepoint.x", None)
                    setcfg("3dlut.whitepoint.y", None)
                    settings.append(lang.getstr("whitepoint"))
                elif line[0] == "TARGET_WHITE_XYZ":
                    XYZ = value.split()  # noqa: N806
                    i = 0
                    try:
                        for component in XYZ:
                            # Normalize to 0.0 - 1.0
                            XYZ[i] = float(component) / 100
                            i += 1
                    except ValueError:
                        continue
                    x, y, Y = XYZ2xyY(XYZ[0], XYZ[1], XYZ[2])  # noqa: N806
                    XYZ2CCT(XYZ[0], XYZ[1], XYZ[2])
                    if lang.getstr("whitepoint") not in settings:
                        setcfg("whitepoint.colortemp", None)
                        setcfg("whitepoint.x", round(x, 4))
                        setcfg("whitepoint.y", round(y, 4))
                        setcfg("3dlut.whitepoint.x", round(x, 4))
                        setcfg("3dlut.whitepoint.y", round(y, 4))
                        self.worker.options_dispcal.append(
                            "-w{},{}".format(
                                getcfg("whitepoint.x"), getcfg("whitepoint.y")
                            )
                        )
                        settings.append(lang.getstr("whitepoint"))
                    setcfg("calibration.luminance", stripzeros(round(Y * 100, 3)))
                    self.worker.options_dispcal.append(
                        "-b{}".format(getcfg("calibration.luminance"))
                    )
                    settings.append(lang.getstr("calibration.luminance"))
                elif line[0] == "TARGET_GAMMA":
                    setcfg("trc", None)
                    if value in ("L_STAR", "REC709", "SMPTE240M", "sRGB"):
                        setcfg("trc.type", "g")
                    if value == "L_STAR":
                        setcfg("trc", "l")
                    elif value == "REC709":
                        setcfg("trc", "709")
                    elif value == "SMPTE240M":
                        setcfg("trc", "240")
                    elif value == "sRGB":
                        setcfg("trc", "s")
                    else:
                        try:
                            value = stripzeros(value)
                            if float(value) < 0:
                                setcfg("trc.type", "G")
                                value = abs(value)
                            else:
                                setcfg("trc.type", "g")
                            setcfg("trc", value)
                        except ValueError:
                            continue
                    self.worker.options_dispcal.append(
                        "-" + getcfg("trc.type") + str(getcfg("trc"))
                    )
                    settings.append(lang.getstr("trc"))
                elif line[0] == "DEGREE_OF_BLACK_OUTPUT_OFFSET":
                    setcfg("calibration.black_output_offset", stripzeros(value))
                    self.worker.options_dispcal.append(
                        "-f{}".format(getcfg("calibration.black_output_offset"))
                    )
                    settings.append(lang.getstr("calibration.black_output_offset"))
                elif line[0] == "BLACK_POINT_CORRECTION":
                    if stripzeros(value) >= 0:
                        black_point_correction = True
                        setcfg("calibration.black_point_correction", stripzeros(value))
                        self.worker.options_dispcal.append(
                            "-k{}".format(getcfg("calibration.black_point_correction"))
                        )
                    settings.append(lang.getstr("calibration.black_point_correction"))
                elif line[0] == "TARGET_BLACK_BRIGHTNESS":
                    setcfg("calibration.black_luminance", stripzeros(value))
                    self.worker.options_dispcal.append(
                        "-B{}".format(getcfg("calibration.black_luminance"))
                    )
                    settings.append(lang.getstr("calibration.black_luminance"))
                elif line[0] == "QUALITY":
                    setcfg("calibration.quality", value.lower()[0])
                    self.worker.options_dispcal.append(
                        "-q{}".format(getcfg("calibration.quality"))
                    )
                    settings.append(lang.getstr("calibration.quality"))
        if not black_point_correction:
            setcfg("calibration.black_point_correction.auto", 1)

        setcfg("calibration.file", path)
        self.update_controls(update_profile_name=update_profile_name)
        if "CTI3" in ti3_lines:
            debug_print("[D] load_cal_handler testchart.file:", path)
            setcfg("testchart.file", path)
        writecfg()
        if load_vcgt:
            # load calibration into lut
            self.load_cal(silent=True)
        if len(settings) == 0:
            msg = lang.getstr("no_settings")
        else:
            msg = lang.getstr("settings_loaded", ", ".join(settings))
        if not silent and len(settings) == 0:
            InfoDialog(
                self,
                msg=msg + "\n" + path,
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-information"),
            )
            if (
                load_vcgt
                and getattr(self, "lut_viewer", None)
                and sys.platform == "win32"
            ):
                # Needed under Windows when using double buffering
                self.lut_viewer.Refresh()

    def update_whitepoint_config_from_temperature(self) -> None:
        """Update the whitepoint configuration from the color temperature."""
        if getcfg("whitepoint.colortemp", False):
            # Color temperature
            if getcfg("whitepoint.colortemp.locus") == "T":
                # Planckian locus
                xyY = planckianCT2xyY(getcfg("whitepoint.colortemp"))  # noqa: N806
            else:
                # Daylight locus
                xyY = CIEDCCT2xyY(getcfg("whitepoint.colortemp"))  # noqa: N806
                # Update 3D LUT whitepoint target
            if xyY:
                setcfg("3dlut.whitepoint.x", xyY[0])
                setcfg("3dlut.whitepoint.y", xyY[1])
            else:
                setcfg("3dlut.whitepoint.x", None)
                setcfg("3dlut.whitepoint.y", None)

    def update_ccmx_items_from_path(
        self, ccmx: str, path: str, ccxxsetting: str, update_ccmx_items: bool
    ) -> bool:
        """Update the colorimeter correction matrix items based on the path.

        Args:
            ccmx (str): The colorimeter correction matrix file path.
            path (str): The path to the profile.
            ccxxsetting (str): The colorimeter correction setting.
            update_ccmx_items (bool): Whether to update the CCMX items.

        Returns:
            bool: True if CCMX items were updated, False otherwise.
        """
        if not ccmx:
            ccxx = safe_glob(
                os.path.join(os.path.dirname(path), "*.ccmx")
            ) or safe_glob(os.path.join(os.path.dirname(path), "*.ccss"))
            if ccxx and len(ccxx) == 1:
                ccmx = ccxx[0]
                update_ccmx_items = True
        if ccmx:
            setcfg(
                "colorimeter_correction_matrix_file",
                f"{ccxxsetting}:{ccmx}",
            )
        return update_ccmx_items

    def set_profile_quality_config_with_option(self, option: str) -> None:
        """Set the profile quality configuration.

        Args:
            option (str): The option string containing the profile quality
                setting.
        """
        setcfg("profile.quality", option[1])

    def set_profile_quality_b2a_config_with_option(self, option: str) -> None:
        """Set the profile quality B2A configuration.

        Args:
            option (str): The option string containing the profile quality B2A
                setting.
        """
        setcfg("profile.quality.b2a", option[1] or "l")

    def set_profile_black_point_compenstation_config_with_option(
        self, option: str, is_preset: bool, is_3dlut_preset: bool
    ) -> None:
        """Set the profile black point compensation configuration.

        Args:
            option (str): The option string containing the profile black point
                compensation setting.
            is_preset (bool): Whether the profile is a preset.
            is_3dlut_preset (bool): Whether the profile is a 3D LUT preset.
        """
        if is_preset and not is_3dlut_preset and sys.platform == "darwin":
            # Force profile type to single shaper + matrix
            # due to OS X bugs with cLUT profiles and
            # matrix profiles with individual shaper curves
            option = "aS"
            # Force black point compensation due to OS X
            # bugs with non BPC profiles
            setcfg("profile.black_point_compensation", 1)
        setcfg("profile.type", option[1])

    def set_gamap_profile_config_with_option(self, option: str) -> None:
        """Set the gamap profile configuration.

        Args:
            option (str): The option string containing the profile.
        """
        option = option.split(None, 1)
        setcfg("gamap_profile", option[-1][1:-1])
        setcfg("gamap_perceptual", 1)
        if option[0:1] == "S":
            setcfg("gamap_saturation", 1)

    def set_gamap_src_viewcond_config_with_option(self, option: str) -> None:
        """Set the gamap source view condition configuration.

        Args:
            option (str): The option string containing the source view
                condition.
        """
        setcfg("gamap_src_viewcond", option[1:])

    def set_gamap_out_viewcond_config_with_option(self, option: str) -> None:
        """Set the gamap output view condition configuration.

        Args:
            option (str): The option string containing the output view
                condition.
        """
        setcfg("gamap_out_viewcond", option[1:])

    def set_gamap_perceptual_intent_config_with_option(self, option: str) -> None:
        """Set the gamap perceptual intent configuration.

        Args:
            option (str): The option string containing the perceptual intent.
        """
        setcfg("gamap_perceptual_intent", option[1:])

    def set_gamap_saturation_intent_config_with_option(self, option: str) -> None:
        """Set the gamap saturation intent configuration.

        Args:
            option (str): The option string containing the saturation intent.
        """
        setcfg("gamap_saturation_intent", option[1:])

    def set_display_number_config_with_option(
        self, option: str, display_match: bool, display_changed: bool
    ) -> tuple[bool, bool]:
        """Set the display number configuration based on the option.

        Args:
            option (str): The option string containing the display number.
            display_match (bool): Current state of display match.
            display_changed (bool): Current state of display change.

        Returns:
            tuple[bool, bool]: Updated display match and change states.
        """
        if option[1:] in ("web", "madvr"):
            # Special case web and madvr so it can be used in
            # preset templates which are TI3 files
            for i, display_name in enumerate(self.worker.display_names):
                if display_name.lower() == option[1:]:
                    # Found it
                    display_match = True
                    if getcfg("display.number") != i + 1:
                        setcfg("display.number", i + 1)
                        self.get_set_display()
                        display_changed = True
                    break
        return display_match, display_changed

    def set_interactive_display_adjustment_config_with_option(
        self, option: str
    ) -> None:
        """Set the interactive display adjustment configuration.

        Args:
            option (str): The option string containing the interactive display
                adjustment setting.
        """
        setcfg("calibration.interactive_display_adjustment", 0)

    def set_calibration_quality_config_with_option(self, option: str) -> None:
        """Set the calibration quality configuration.

        Args:
            option (str): The option string containing the calibration quality
                setting.
        """
        setcfg("calibration.quality", option[1])

    def set_measurement_mode_config_with_option(self, option: str) -> None:
        """Set the measurement mode configuration.

        Args:
            option (str): The option string containing the measurement mode
                setting.
        """
        if getcfg("measurement_mode") != "auto":
            setcfg("measurement_mode", option[1])

    def set_whitepoint_temperature_config_with_option(self, option: str) -> None:
        """Set the whitepoint temperature configuration.

        Args:
            option (str): The option string containing the whitepoint
                temperature setting.
        """
        setcfg("whitepoint.colortemp.locus", option[0:1])
        if option[1:]:
            setcfg("whitepoint.colortemp", int(float(option[1:])))
        setcfg("whitepoint.x", None)
        setcfg("whitepoint.y", None)

    def set_whitepoint_config_with_option(self, option: str) -> None:
        """Set the whitepoint configuration.

        Args:
            option (str): The option string containing the whitepoint setting.
        """
        option = option[1:].split(",")
        setcfg("whitepoint.colortemp", None)
        setcfg("whitepoint.x", option[0])
        setcfg("whitepoint.y", option[1])
        setcfg("3dlut.whitepoint.x", option[0])
        setcfg("3dlut.whitepoint.y", option[1])

    def set_calibration_luminance_config_with_option(self, option: str) -> None:
        """Set the calibration luminance configuration.

        Args:
            option (str): The option string containing the luminance setting.
        """
        setcfg("calibration.luminance", option[1:])

    def set_tone_response_curve_config_with_option(self, option: str) -> None:
        """Set the tone response curve configuration.

        Args:
            option (str): The option string containing the tone response curve
                setting.
        """
        setcfg("trc.type", option[0:1])
        setcfg("trc", option[1:])

    def set_calibration_black_output_offset_config_with_option(
        self, option: str
    ) -> None:
        """Set the calibration black output offset configuration.

        Args:
            option (str): The option string containing the black output.
        """
        setcfg("calibration.black_output_offset", option[1:])

    def set_ambient_view_condition_adjustment_config_with_option(
        self, option: str
    ) -> None:
        """Set the ambient view condition adjustment configuration.

        Args:
            option (str): The option string containing the ambient view
                condition adjustment setting.
        """
        try:
            ambient = float(option[1:])
        except ValueError:
            pass
        else:
            setcfg("calibration.ambient_viewcond_adjust", 1)
            # Argyll dispcal uses 20% of ambient (in lux,
            # fixed steradiant of 3.1415) as adapting
            # luminance, but we assume it already *is*
            # the adapting luminance. To correct for this,
            # scale so that dispcal gets the correct value.
            setcfg(
                "calibration.ambient_viewcond_adjust.lux",
                ambient / 5.0,
            )

    def set_black_point_correction_config_with_option(
        self, option: str, black_point_correction: bool
    ) -> bool:
        """Set the calibration black point correction configuration.

        Args:
            option (str): The option string containing the black point correction
                setting.
            black_point_correction (bool): Current state of black point correction.

        Returns:
            tuple(bool): Updated state of black point correction.
        """
        if stripzeros(option[1:]) >= 0:
            black_point_correction = True
            setcfg("calibration.black_point_correction", option[1:])
        return (black_point_correction,)

    def set_calibration_black_point_rate_config_with_option(self, option: str) -> None:
        """Set the calibration black point rate configuration.

        Args:
            option (str): The option string containing the black point rate
                setting.
        """
        setcfg("calibration.black_point_rate", option[1:])

    def set_calibration_black_luminance_config_with_option(self, option: str) -> None:
        """Set the calibration black luminance configuration.

        Args:
            option (str): The option string containing the black luminance
                setting.
        """
        setcfg("calibration.black_luminance", option[1:])

    def set_measureframe_config_with_option(self, option: str) -> None:
        """Set the measure frame configuration.

        Args:
            option (str): The option string containing the measure frame
                setting.
        """
        if len(option[1:]) >= 5:
            setcfg("dimensions.measureframe", option[1:])
            setcfg("dimensions.measureframe.unzoomed", option[1:])

    def set_measurement_mode_adaptive_config_with_option(self, option: str) -> None:
        """Set the measurement mode adaptive configuration.

        Args:
            option (str): The option string containing the adaptive setting.
        """
        setcfg("measurement_mode.adaptive", option)

    def set_measurement_mode_highres_config_with_option(self, option: str) -> None:
        """Set the measurement mode high resolution configuration.

        Args:
            option (str): The option string containing the high resolution
                setting.
        """
        setcfg("measurement_mode.highres", option)

    def set_measurement_mode_projector_config_with_option(self, option: str) -> None:
        """Set the measurement mode projector configuration.

        Args:
            option (str): The option string containing the projector setting.
        """
        if len(option[1:]) == 0:
            setcfg("measurement_mode.projector", 1)
        self.set_measureframe_config_with_option(option)

    def set_measure_darken_background_config_with_option(self, option: str) -> None:
        """Set the measure darken background configuration.

        Args:
            option (str): The option string containing the darken background
                setting.
        """
        setcfg("measure.darken_background", option)

    def set_ccss_config_with_option(self, option: str, path: str) -> tuple[str, bool]:
        """Set the colorimeter correction matrix file configuration.

        Args:
            option (str): The option string containing the ccmx file path.
            path (str): The path to the calibration file.

        Returns:
            tuple[str, bool]: The ccmx file path and a boolean indicating
                whether to update ccmx items.
        """
        option = option.split(None, 1)
        ccmx = option[-1][1:-1]
        if not os.path.isabs(ccmx):
            ccmx = os.path.join(os.path.dirname(path), ccmx)
        # Need to update ccmx items again even if
        # comport_ctrl_handler already did
        update_ccmx_items = True
        return ccmx, update_ccmx_items

    def set_drift_compensation_config_with_option(self, option: str) -> None:
        """Set the drift compensation configuration.

        Args:
            option (str): The option string containing the drift compensation
                setting.
        """
        if "b" in option[1:]:
            setcfg("drift_compensation.blacklevel", 1)
        if "w" in option[1:]:
            setcfg("drift_compensation.whitelevel", 1)

    def set_tristimulus_observer_config_with_option(self, option: str) -> bool:
        """Set the tristimulus observer configuration.

        Args:
            option (str): The option string containing the observer setting.

        Returns:
            tuple(bool): Always returns True in a tuple.
        """
        setcfg("observer", option[1:])
        # Need to update ccmx items again even if
        # comport_ctrl_handler already did because CCMX
        # observer may override calibration observer
        return (True,)

    def set_video_levels_config_with_option(self) -> None:
        """Set the video levels configuration."""
        setcfg("patterngenerator.use_video_levels", 1)
        self.update_output_levels_ctrl()

    def get_calibration_file_path(self) -> str:
        """Get the path to the calibration file.

        Returns:
            str: The path to the calibration file.
        """
        path = ""
        wildcard = lang.getstr("filetype.cal_icc") + "|*.cal;*.icc;*.icm"
        sevenzip = get_program_file("7z", "7-zip")
        if sevenzip:
            wildcard += ";*.7z"
        wildcard += ";*.tar.gz;*.tgz;*.zip"
        default_dir, default_file = get_verified_path("last_cal_or_icc_path")
        dlg = wx.FileDialog(
            self,
            lang.getstr("dialog.load_cal"),
            defaultDir=default_dir,
            defaultFile=default_file,
            wildcard=wildcard,
            style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
        )
        dlg.Center(wx.BOTH)
        if dlg.ShowModal() == wx.ID_OK:
            path = dlg.GetPath()
        dlg.Destroy()
        return path

    def remove_missing_calibration(self, path: str) -> None:
        """Remove a missing calibration file from the recent calibrations list.

        Args:
            path (str): The path to the missing calibration file.
        """
        sel = self.calibration_file_ctrl.GetSelection()
        if len(self.recent_cals) > sel and self.recent_cals[sel] == path:
            self.recent_cals.remove(self.recent_cals[sel])
            recent_cals = self.get_unpreseted_recent_calibrations()
            setcfg("recent_cals", os.pathsep.join(recent_cals))
            self.calibration_file_ctrl.Delete(sel)
            cal = getcfg("calibration.file", False) or ""
            if cal not in self.recent_cals:
                self.recent_cals.append(cal)
                # The case-sensitive index could fail because of
                # case insensitive file systems, e.g. if the
                # stored filename string is
                # "C:\Users\Name\AppData\DisplayCAL\storage\MyFile"
                # but the actual filename is
                # "C:\Users\Name\AppData\DisplayCAL\storage\myfile"
                # (maybe because the user renamed the file)
            idx = index_fallback_ignorecase(self.recent_cals, cal)
            self.calibration_file_ctrl.SetSelection(idx)
        InfoDialog(
            self,
            msg=lang.getstr("file.missing", path),
            ok=lang.getstr("ok"),
            bitmap=get_icon(32, "dialog-error"),
        )

    def parse_calibration_file(self, path: str) -> None | list[bytes]:
        """Parse the calibration file at the given path.

        Args:
            path (str): The path to the calibration file.

        Returns:
            None | list[bytes]: List of lines from the calibration file or None
                if invalid.
        """
        ti3_lines = []
        profile = None
        ext = os.path.splitext(path)[-1]
        if ext.lower() in ICCPROFILE_FILE_EXTENSIONS:
            profile = self.validate_icc_profile(path)
            if not profile:
                return profile, None
            ti3_lines = self.validate_calibration_data(profile, path)
            if ti3_lines is None:
                return profile, None
        else:
            try:
                with open(path, "rb") as cal:
                    ti3_lines = [line.strip() for line in cal]
            except Exception:
                InfoDialog(
                    self,
                    msg=lang.getstr("error.file.open", path),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                return profile, None
        return profile, ti3_lines

    def validate_icc_profile(self, path: str) -> None | ICCProfile:
        """Validate if the given path points to a valid ICC profile."""
        try:
            profile = ICCProfile(path)
        except (OSError, ICCProfileInvalidError):
            InfoDialog(
                self,
                msg=lang.getstr("profile.invalid") + "\n" + path,
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            return None
        if profile.profileClass != b"mntr" or profile.colorSpace != b"RGB":
            InfoDialog(
                self,
                msg=lang.getstr(
                    "profile.unsupported",
                    (
                        profile.profileClass.decode("utf-8"),
                        profile.colorSpace.decode("utf-8"),
                    ),
                )
                + "\n"
                + path,
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            return None
        return profile

    def validate_calibration_data(
        self, profile: ICCProfile, path: str
    ) -> None | list[bytes]:
        """Validate the calibration data in the ICC profile.

        Args:
            profile (ICCProfile): The ICC profile object.
            path (str): The path to the ICC profile file.

        Returns:
            None | list[bytes]: List of lines from the calibration data or None
                if invalid.
        """
        ti3_lines = []
        cied = profile.tags.get("CIED")
        if cied:
            with BytesIO(cied) as cal:
                ti3_lines = [line.strip() for line in cal]
        else:
            targ = profile.tags.get("targ")
            from DisplayCAL.icc_profile import Text

            if targ and isinstance(targ, Text):
                tag_data = targ.tagData
                with BytesIO(tag_data) as cal:
                    ti3_lines = [line.strip() for line in cal]
            else:
                InfoDialog(
                    self,
                    msg=lang.getstr("profile.no_targ") + "\n" + path,
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
                return None
        return ti3_lines

    def delete_calibration_handler(self, event: wx.Event) -> None:
        """Delete calibration file and related files.

        Args:
            event (wx.Event): The event object.
        """
        cal = getcfg("calibration.file", False)
        if not cal or not os.path.exists(cal):
            return
        caldir = os.path.dirname(cal)
        try:
            dircontents = os.listdir(caldir)
        except Exception as exception:
            InfoDialog(
                self,
                msg=str(exception),
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )
            return
        self.initialize_related_files(cal, dircontents)
        result = self.display_delete_confirmation()
        if result != wx.ID_OK:
            return

        delete_related_files, orphan_related_files = (
            self.delete_related_files_and_cleanup(cal, dircontents)
        )
        # The case-sensitive index could fail because of
        # case-insensitive file systems, e.g. if the
        # stored filename string is
        # "C:\Users\Name\AppData\DisplayCAL\storage\MyFile"
        # but the actual filename is
        # "C:\Users\Name\AppData\DisplayCAL\storage\myfile"
        # (maybe because the user renamed the file)
        idx = index_fallback_ignorecase(self.recent_cals, cal)
        self.recent_cals.remove(cal)
        self.calibration_file_ctrl.Delete(idx)
        setcfg("calibration.file", None)
        setcfg("settings.changed", 1)
        recent_cals = self.get_unpreseted_recent_calibrations()
        setcfg("recent_cals", os.pathsep.join(recent_cals))
        update_colorimeter_correction_matrix_ctrl_items = False
        update_testcharts = False
        for path in delete_related_files:
            if path not in orphan_related_files:
                if os.path.splitext(path)[1].lower() in (".ccss", ".ccmx"):
                    self.delete_colorimeter_correction_matrix_ctrl_item(path)
                    update_colorimeter_correction_matrix_ctrl_items = True
                elif path in self.testcharts:
                    update_testcharts = True
        if update_testcharts:
            self.set_testcharts()
        self.update_controls(False, update_colorimeter_correction_matrix_ctrl_items)
        self.load_display_profile_cal()

    def initialize_related_files(self, cal: str, dircontents: list) -> None:
        """Initialize the related files dict based on the calibration dir contents.

        Args:
            cal (str): The path to the calibration file.
            dircontents (list): List of files in the calibration directory.
        """
        self.related_files = {}
        for entry in dircontents:
            fn, ext = os.path.splitext(entry)
            if ext.lower() in (".app", SCRIPT_EXT):
                fn, ext = os.path.splitext(fn)
            if (
                fn.startswith(os.path.splitext(os.path.basename(cal))[0])
                or ext.lower() in (".ccss", ".ccmx")
                or entry.lower() in ("0_16.ti1", "0_16.ti3", "0_16.log")
            ):
                self.related_files[entry] = True

    def display_delete_confirmation(self) -> None:
        """Display a confirmation dialog for deleting calibration files."""
        self.dlg = dlg = ConfirmDialog(
            self,
            msg=lang.getstr("dialog.confirm_delete"),
            ok=lang.getstr("delete"),
            cancel=lang.getstr("cancel"),
            bitmap=get_icon(32, "dialog-warning"),
        )
        if self.related_files:
            scale = getcfg("app.dpi") / config.get_default_dpi()
            scale = max(scale, 1)
            scrolled = ScrolledPanel(dlg, -1, style=wx.VSCROLL)
            sizer = scrolled.Sizer = wx.BoxSizer(wx.VERTICAL)
            dlg.sizer3.Add(scrolled, flag=wx.TOP | wx.EXPAND, border=12)
            for i, related_file in enumerate(self.related_files):
                if i:
                    sizer.Add((0, 4))
                chk = wx.CheckBox(scrolled, -1, related_file)
                chk.SetValue(self.related_files[related_file])
                dlg.Bind(
                    wx.EVT_CHECKBOX,
                    self.delete_calibration_related_handler,
                    id=chk.GetId(),
                )
                sizer.Add(chk, flag=wx.ALIGN_LEFT)
            scrolled.SetupScrolling()
            scrolled.MinSize = (
                int(
                    min(
                        scrolled.GetVirtualSize()[0]
                        + 4 * scale
                        + wx.SystemSettings_GetMetric(wx.SYS_VSCROLL_X),
                        self.GetDisplay().ClientArea[2] - (12 * 3 + 32) * scale,
                    )
                ),
                int(
                    min(
                        ((chk.Size[1] + 4) * min(len(self.related_files), 20) - 4)
                        * scale,
                        max(
                            self.GetDisplay().ClientArea[3] - dlg.Size[1] - 40 * scale,
                            chk.Size[1],
                        ),
                    )
                ),
            )
            dlg.sizer0.SetSizeHints(dlg)
            dlg.sizer0.Layout()
            dlg.Center()
        result = dlg.ShowModal()
        dlg.Destroy()
        return result

    def delete_related_files_and_cleanup(
        self, cal: str, dircontents: list
    ) -> tuple[list, list]:
        """Delete related files and clean up the calibration directory.

        Args:
            cal (str): The path to the calibration file.
            dircontents (list): List of files in the calibration directory.

        Returns:
            tuple[list, list]: A tuple containing two lists:
                - delete_related_files: List of files intended for deletion.
                - orphan_related_files: List of files that could not be deleted.
        """
        delete_related_files = []
        if self.related_files:
            delete_related_files.extend(
                os.path.join(os.path.dirname(cal), related_file)
                for related_file in self.related_files
                if self.related_files[related_file]
            )
        if sys.platform == "darwin":
            trashcan = lang.getstr("trashcan.mac")
        elif sys.platform == "win32":
            trashcan = lang.getstr("trashcan.windows")
        else:
            trashcan = lang.getstr("trashcan.linux")
        orphan_related_files = delete_related_files
        try:
            if (
                sys.platform == "darwin"
                and len(delete_related_files) + 1 == len(dircontents)
                and ".DS_Store" in dircontents
            ) or len(delete_related_files) == len(dircontents):
                # Delete whole folder
                send2trash([os.path.dirname(cal)])
            else:
                send2trash(delete_related_files)
            orphan_related_files = [
                related_file
                for related_file in delete_related_files
                if os.path.exists(related_file)
            ]
            if orphan_related_files:
                InfoDialog(
                    self,
                    msg=lang.getstr("error.deletion", trashcan)
                    + "\n\n"
                    + "\n".join(
                        os.path.basename(related_file)
                        for related_file in orphan_related_files
                    ),
                    ok=lang.getstr("ok"),
                    bitmap=get_icon(32, "dialog-error"),
                )
        except OSError as exc:
            InfoDialog(
                self,
                msg=f"{lang.getstr('error.deletion', trashcan)}\n\n{exc!s}",
                ok=lang.getstr("ok"),
                bitmap=get_icon(32, "dialog-error"),
            )

        return delete_related_files, orphan_related_files

    def delete_calibration_related_handler(self, event: wx.Event) -> None:
        """Handle checkbox changes in the delete calibration dialog.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        chk = self.dlg.FindWindowById(event.GetId())
        self.related_files[chk.GetLabel()] = chk.GetValue()

    def aboutdialog_handler(self, event: wx.Event) -> None:
        """Open the About dialog.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if hasattr(self, "aboutdialog"):
            self.aboutdialog.Destroy()
        self.aboutdialog = AboutDialog(
            self, -1, lang.getstr("menu.about"), size=(100, 100)
        )
        self.aboutdialog.panel.BackgroundColour = wx.SystemSettings.GetColour(
            wx.SYS_COLOUR_WINDOW
        )
        items = []
        scale = max(getcfg("app.dpi") / config.get_default_dpi(), 1)
        items.append(wx_Panel(self.aboutdialog.panel, -1, size=(-1, round(6 * scale))))
        items[-1].BackgroundColour = "#66CC00"
        items.append(
            get_header(
                self.aboutdialog.panel,
                get_bitmap("theme/header", display_missing_icon=False),
                label=wrap(lang.getstr("header"), 32),
                size=(320, 120),
                repeat_sub_bitmap_h=(220, 0, 2, 184),
            )
        )
        separator = wx.Panel(self.aboutdialog.panel, size=(-1, 1))
        separator.BackgroundColour = wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DLIGHT)
        items.append(separator)
        items.append((1, 12))
        version_title = VERSION_STRING
        items.append(
            [
                HyperLinkCtrl(
                    self.aboutdialog.panel,
                    -1,
                    label=APPNAME,
                    URL=f"https://{DOMAIN}/",
                ),
                wx.StaticText(
                    self.aboutdialog.panel,
                    -1,
                    f" {version_title} © {wrap(AUTHOR, 55)}",
                ),
            ]
        )
        items.append(
            [
                HyperLinkCtrl(
                    self.aboutdialog.panel,
                    -1,
                    label="ArgyllCMS",
                    URL=config.DEFAULTS.get("argyll.domain"),
                ),
                wx.StaticText(
                    self.aboutdialog.panel,
                    -1,
                    " {} © Graeme Gill".format(
                        re.sub(r"(?:\.0)+$", ".0", self.worker.argyll_version_string)
                    ),
                ),
            ]
        )
        items.append(wx.StaticText(self.aboutdialog.panel, -1, ""))
        items.append(
            wx.StaticText(self.aboutdialog.panel, -1, f"{lang.getstr('translations')}:")
        )
        lauthors = {}
        for lcode in lang.LDICT:
            lauthor = lang.LDICT[lcode].get("!author", "")
            language = lang.LDICT[lcode].get("!language", "")
            if lauthor and language:
                if not lauthors.get(lauthor):
                    lauthors[lauthor] = []
                lauthors[lauthor].append(language)
        lauthors = [(lauthors[lauthor], lauthor) for lauthor in lauthors]
        lauthors.sort()
        for langs, lauthor in lauthors:
            items.append(
                wx.StaticText(
                    self.aboutdialog.panel,
                    -1,
                    "{} - {}".format(", ".join(langs), lauthor),
                )
            )
        items.append(wx.StaticText(self.aboutdialog.panel, -1, ""))

        # Apricity OS icons
        items.append(
            [
                HyperLinkCtrl(
                    self.aboutdialog.panel,
                    -1,
                    label="Apricity Icons",
                    URL="https://github.com/Apricity-OS/apricity-icons",
                ),
                wx.StaticText(self.aboutdialog.panel, -1, " © Apricity OS Team"),
            ]
        )

        # Suru icons
        items.append(
            [
                HyperLinkCtrl(
                    self.aboutdialog.panel,
                    -1,
                    label="Suru Icons",
                    URL="https://github.com/snwh/suru-icon-theme",
                ),
                wx.StaticText(self.aboutdialog.panel, -1, " © Sam Hewitt"),
            ]
        )

        # Gnome icons
        items.append(
            [
                wx.StaticText(self.aboutdialog.panel, -1, "Some icons © "),
                HyperLinkCtrl(
                    self.aboutdialog.panel,
                    -1,
                    label="GNOME Project",
                    URL="https://www.gnome.org/",
                ),
            ]
        )

        items.append(wx.StaticText(self.aboutdialog.panel, -1, ""))

        match = re.match(r"([^(]+)\s*(\([^(]+\))?\s*(\[[^[]+\])?", sys.version)
        pyver_long = match.groups() if match else [sys.version]
        items.append(
            [
                HyperLinkCtrl(
                    self.aboutdialog.panel,
                    -1,
                    label="Python",
                    URL="https://www.python.org/",
                ),
                wx.StaticText(self.aboutdialog.panel, -1, " " + pyver_long[0].strip()),
            ]
        )
        items.append(
            [
                HyperLinkCtrl(
                    self.aboutdialog.panel,
                    -1,
                    label="wxPython",
                    URL="https://www.wxpython.org/",
                ),
                wx.StaticText(self.aboutdialog.panel, -1, f" {wx.version()}"),
            ]
        )
        items.append(
            wx.StaticText(
                self.aboutdialog.panel,
                -1,
                lang.getstr("audio.lib", f"{audio._LIB} {audio._LIB_VERSION}"),
            )
        )
        items.append((1, 12))
        self.aboutdialog.add_items(items)
        self.aboutdialog.Layout()
        self.aboutdialog.Center()
        self.aboutdialog.Show()

    def readme_handler(self, event: wx.Event) -> None:
        """Open the README file in the default web browser.

        Args:
            event (wx.Event): The event that triggered the action.
        """
        readme = get_data_path("README-fr.html") if lang.getcode() == "fr" else None
        if not readme:
            readme = get_data_path("README.html")
        if readme:
            launch_file(readme)

    def license_handler(self, event: wx.Event) -> None:
        """Open the license file in the default text editor.

        Args:
            event (wx.Event): The event that triggered the action.
        """
        license_path = get_data_path("LICENSE.txt")
        if not license_path:
            # Debian
            license_path = "/usr/share/common-licenses/GPL-3"
        if license_path and os.path.isfile(license_path):
            launch_file(license_path)

    def help_support_handler(self, event: wx.Event) -> None:
        """Open the help and support page in the default web browser.

        Args:
            event (wx.Event): The event that triggered the action.
        """
        launch_file(f"{DEVELOPMENT_HOME_PAGE}/issues")

    def bug_report_handler(self, event: wx.Event) -> None:
        """Open the bug report page in the default web browser.

        Args:
            event (wx.Event): The event that triggered the action.
        """
        launch_file(f"{DEVELOPMENT_HOME_PAGE}/issues")

    def app_update_check_handler(
        self, event: wx.Event, silent: bool = False, argyll: bool = False
    ) -> None:
        """Check for application updates.

        Args:
            event (wx.Event): The event that triggered the check.
            silent (bool): If True, suppress user notifications.
            argyll (bool): If True, check for Argyll updates instead.
        """
        if (
            not hasattr(self, "app_update_check")
            or not self.app_update_check.is_alive()
        ):
            self.app_update_check = threading.Thread(
                target=app_update_check,
                name="ApplicationUpdateCheck",
                args=(self, silent, False, argyll),
            )
            self.app_update_check.start()

    def app_auto_update_check_handler(self, event: wx.Event) -> None:
        """Handle the application auto-update check menu item toggle.

        Args:
            event (wx.Event): The event that triggered the toggle.
        """
        setcfg("update_check", int(self.menuitem_app_auto_update_check.IsChecked()))

    def infoframe_toggle_handler(
        self, event: wx.Event = None, show: None | bool = None
    ) -> None:
        """Toggle the visibility of the info frame (log window).

        Args:
            event (wx.Event, optional): The event that triggered the toggle.
            show (bool, optional): If provided, explicitly set the visibility.
        """
        if show is None:
            show = not self.infoframe.IsShownOnScreen()
        setcfg("log.show", int(show))
        if show:
            self.log()
        else:
            LOGBUFFER.truncate(0)
        self.infoframe.Show(show)
        self.menuitem_show_log.Check(show)
        self.menuitem_log_autoshow.Enable(not show)

    def infoframe_autoshow_handler(self, event: wx.Event) -> None:
        """Handle the autoshow log menu item toggle.

        Args:
            event (wx.Event): The event that triggered the toggle.
        """
        setcfg("log.autoshow", int(self.menuitem_log_autoshow.IsChecked()))

    def HideAll(self) -> None:  # noqa: N802
        """Hide all top-level windows of the application."""
        self.stop_timers()
        self.gamapframe.Hide() if hasattr(self, "gamapframe") else None
        self.aboutdialog.Hide() if hasattr(self, "aboutdialog") else None
        self.extra_args.Hide() if hasattr(self, "extra_args") else None
        LOGBUFFER.truncate(0)
        self.infoframe.Hide()
        self.tcframe.Hide() if hasattr(self, "tcframe") else None
        self.lut_viewer.Hide() if getattr(
            self, "lut_viewer", None
        ) and self.lut_viewer.IsShownOnScreen() else None
        self.lut3dframe.Hide() if getattr(self, "lut3dframe", None) else None
        self.reportframe.Hide() if getattr(self, "reportframe", None) else None
        self.synthiccframe.Hide() if getattr(self, "synthiccframe", None) else None
        self.wpeditor.Close() if getattr(self, "wpeditor", None) else None
        for profile_info in list(self.profile_info.values()):
            profile_info.Close()
        while self.measureframes:
            measureframe = self.measureframes.pop()
            measureframe.Close() if measureframe else None
        for window in list(wx.GetTopLevelWindows()):
            if window and window is not self and window.IsShown():
                print(
                    "Closing",
                    window,
                    "'{}'".format(getattr(window, "Title", window.Name)),
                )
                window.Close()
        self.Hide()
        self.enable_menus(False)

    def Show(self, show: bool = True, start_timers: bool = True) -> None:  # noqa: N802
        """Show or hide the main application window.

        Args:
            show (bool): Whether to show or hide the window.
            start_timers (bool): Whether to start timers after showing the window.
        """
        if show and self.measureframe.IsShown():
            self.measureframe.Hide()
        if not self.IsShownOnScreen():
            if hasattr(self, "tcframe"):
                self.tcframe.Show(getcfg("tc.show"))
            if getcfg("log.show"):
                wx.CallAfter(self.infoframe_toggle_handler, show=True)
            if (
                LUTFrame
                and getcfg("lut_viewer.show")
                and self.worker.argyll_version > [0, 0, 0]
            ):
                if getattr(self, "lut_viewer", None):
                    self.init_lut_viewer(show=True)
                else:
                    # Using wx.CallAfter fixes wrong positioning under wxGTK
                    # with wxPython 3 on first initialization
                    wx.CallAfter(self.init_lut_viewer, show=True)
            else:
                setcfg("lut_viewer.show", 0)
            for profile_info in reversed(list(self.profile_info.values())):
                profile_info.Show()
        if start_timers:
            self.start_timers()
        self.enable_menus()
        wx.Frame.Show(self, show)
        if self.worker.progress_wnd and self.worker.progress_wnd.IsShown():
            self.Lower()
            self.worker.progress_wnd.Raise()
        self.update_layout()

    def veto_close_event(self, event: wx.Event) -> None:
        """Veto the close event if conditions are not met.

        Args:
            event (wx.Event): The close event to veto.
        """
        if isinstance(event, wx.CloseEvent) and event.CanVeto():
            event.Veto()

    def OnClose(self, event: wx.Event = None) -> None:  # noqa: N802
        """Handle application close event.

        Args:
            event (wx.Event): The close event, if any.
        """
        if getattr(self.worker, "thread", None) and self.worker.thread.is_alive():
            self.veto_close_event(event)
            self.worker.abort_subprocess(True)
            return
        if sys.platform == "darwin" or DEBUG:
            self.focus_handler(event)
        if not hasattr(self, "tcframe") or self.tcframe.tc_close_handler():
            # If resources are missing, XRC shows an error dialog.
            # If the user never closees that dialog before he quits the
            # application, this dialog will hinder exiting the main loop.
            win = self.get_top_window()
            if isinstance(win, wx.Dialog) and win.IsModal():
                win.RequestUserAttention()
                win.Raise()
                self.veto_close_event(event)
                return
            for win in list(wx.GetTopLevelWindows()):
                if (
                    win
                    and not win.IsBeingDeleted()
                    and isinstance(win, VisualWhitepointEditor)
                ):
                    win.Close(force=True)
            writecfg()
            if getattr(self, "thread", None) and self.thread.is_alive():
                self.Disable()
                print("Waiting for child thread to exit...")
                self.thread.join()
            self.listening = False
            if isinstance(getattr(self.worker, "madtpg", None), madvr.MadTPGNet):
                self.worker.madtpg.shutdown()
            for patterngenerator in list(self.worker.patterngenerators.values()):
                patterngenerator.listening = False
            self.HideAll()
            if (
                self.worker.tempdir
                and os.path.isdir(self.worker.tempdir)
                and not os.listdir(self.worker.tempdir)
            ):
                self.worker.wrapup(False)
            wx.GetApp().ExitMainLoop()
        else:
            self.veto_close_event(event)


if (
    sys.platform == "darwin" and intlist(platform.mac_ver()[0].split(".")) >= [10, 10]
) or os.getenv("XDG_SESSION_TYPE") == "wayland":
    # Use a wx.Dialog so we can use ShowModal() which seems to be the only way to
    # center the splash screen under Wayland.
    # Under macOS, it fixes the splash screen not animating when running
    # frozen (from an app bundle created using py2app).
    start_cls = wx.Dialog
else:
    start_cls = wx.Frame


class StartupFrame(start_cls):
    """Splash screen."""

    def __init__(self) -> None:
        super().__init__()
        title = f"{APPNAME} {VERSION_STRING}"
        start_cls.__init__(
            self,
            None,
            title="{}: {}".format(title, lang.getstr("startup")),
            style=wx.FRAME_SHAPED | wx.NO_BORDER,
        )
        self.SetIcons(config.get_icon_bundle([256, 48, 32, 16], APPNAME))
        if wx.VERSION >= (2, 8, 12, 1):
            # Setup shape. Required to get rid of window shadow under Ubuntu.
            # Note that shaped windows seem to be broken (won't show at all)
            # with wxGTK 2.8.12.0 and possibly earlier.
            self.mask_bmp = get_bitmap("theme/splash-mask")
            if wx.Platform == "__WXGTK__":
                # wxGTK requires that the window be created before you can
                # set its shape, so delay the call to SetWindowShape until
                # this event.
                self.Bind(wx.EVT_WINDOW_CREATE, self.SetWindowShape)
            elif sys.platform != "darwin" or intlist(
                platform.mac_ver()[0].split(".")
            ) < [10, 14]:
                # On wxMSW and wxMac the window has already been created.
                self.SetWindowShape()

        # Setup splash screen
        self.use_simple_splash = bool(getcfg("splash.simple"))
        self.splash_bmp = get_bitmap(
            "theme/splash-simple" if self.use_simple_splash else "theme/splash"
        )
        self.splash_anim = []
        for pth in get_data_path("theme/splash_anim", r"\.png$") or []:
            self.splash_anim.append(get_bitmap(os.path.splitext(pth)[0]))
        self.zoom_scales = []
        if getcfg("splash.zoom"):
            # Zoom in instead of fade
            numframes = 15
            self.splash_alpha = self.splash_bmp.ConvertToImage().GetAlphaData()
            minv = 1.0 / self.splash_bmp.Size[0]
            for x in range(numframes):
                scale = minv + colormath.special_pow(
                    0.35 + x / (numframes - 1.0) * (1 - 0.35), -2084
                ) * (1 - minv)
                self.zoom_scales.append(scale)
            self.zoom_scales.append(1.02)
            self.zoom_scales.append(1.0)
        # Fade in major version number
        self.splash_version_anim = []
        splash_version = get_bitmap("theme/splash_version")
        if splash_version:
            im = splash_version.ConvertToImage()
            for alpha in [0, 0.2, 0.4, 0.6, 0.8, 1, 0.95, 0.9, 0.85, 0.8, 0.75]:
                imcopy = im.AdjustChannels(1, 1, 1, alpha)
                self.splash_version_anim.append(imcopy.ConvertToBitmap())
        self.frame = 0
        clientarea = self.GetDisplay().ClientArea
        self.splash_x, self.splash_y = (
            clientarea[0] + int(clientarea[2] / 2.0 - self.splash_bmp.Size[0] / 2.0),
            clientarea[1] + int(clientarea[3] / 2.0 - self.splash_bmp.Size[1] / 2.0),
        )
        self.Pulse(
            "\n".join(
                [
                    lang.getstr("welcome_back" if hascfg("recent_cals") else "welcome"),
                    lang.getstr("startup"),
                ]
            )
        )

        self._bufferbitmap = wx.EmptyBitmap(
            self.splash_bmp.Size[0], self.splash_bmp.Size[1]
        )
        self._buffereddc = wx.MemoryDC(self._bufferbitmap)
        self.worker = Worker()
        self.grab_image()
        self.SetClientSize(self.splash_bmp.Size)
        self.SetPosition((self.splash_x, self.splash_y))
        self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnEraseBackground)
        self.Bind(wx.EVT_PAINT, self.OnPaint)
        if len(self.zoom_scales):
            self._alpha = 255
        else:
            self.SetTransparent(0)
            self._alpha = 0

        audio.safe_init()
        if audio._LIB:
            print(lang.getstr("audio.lib", f"{audio._LIB} {audio._LIB_VERSION}"))
        # Startup sound
        # Needs to be stereo!
        if getcfg("startup_sound.enable"):
            self.startup_sound = audio.Sound(get_data_path("theme/intro_new.wav"))
            self.startup_sound.volume = 0.8
            self.startup_sound.safe_play()

        # We need to use CallLater instead of CallAfter otherwise dialogs
        # will not show while the main frame is not yet initialized
        wx.CallLater(1, self.startup)
        self.ShowModal() if isinstance(self, wx.Dialog) else self.Show()

    def gamma_correct_image(
        self, bmp_path: str, gamut: str = "Rec. 709", gamma: float = 2.0
    ) -> tuple[str, str]:
        """Gamma correct the image to the given gammut and gamma.

        Args:
            bmp_path (str): Path to the bitmap image to be corrected.
            gamut (str): The color gamut to convert to (default: "Rec. 709").
            gamma (float): The gamma value to apply (default: 2.0).

        Returns:
            tuple[str, str]: Tuple containing the paths to the corrected bitmap
                and the TIFF file.
        """
        # We want to color convert the screenshot to the given gamut and gamma
        # to get rid of visible color differences.
        try:
            import PIL
            import PIL.Image
            import PIL.ImageCms
        except ImportError as exception:
            PIL = None  # noqa: N806
            print("Info: Couldn't import PIL:", exception)
        else:
            gamut_with_gamma = list(colormath.get_rgb_space(gamut))
            gamut_with_gamma[0] = gamma
            gamut_with_gamma_profile = ICCProfile.from_rgb_space(
                gamut_with_gamma,
                b"%s gamma %s"
                % (bytes(gamut, "utf-8"), bytes(str(f"{gamma:.1f}"), "utf-8")),
            )
            gamut_with_gamma_io = BytesIO(gamut_with_gamma_profile.data)
            try:
                gamut_with_gamma_cms = PIL.ImageCms.getOpenProfile(gamut_with_gamma_io)
            except Exception as exception:
                gamut_with_gamma_cms = None
                print("Info:", exception)

        tif_path = os.path.join(self.worker.tempdir, "screencap.tif")
        if not PIL or not gamut_with_gamma_cms:
            return bmp_path, tif_path

        # Open screenshot as PIL image
        try:
            pim = PIL.Image.open(bmp_path)
        except Exception as exception:
            print("Info: Couldn't open image:", exception)
        else:
            if "icc_profile" in pim.info:
                # Get embedded ICC profile from image
                inprofile_io = BytesIO(pim.info["icc_profile"])
                # Convert from display profile to the given gamma and gamut
                try:
                    inprofile_cms = PIL.ImageCms.getOpenProfile(inprofile_io)
                    PIL.ImageCms.profileToProfile(
                        pim, inprofile_cms, gamut_with_gamma_cms, inPlace=True
                    )
                    # Convert PIL image to wx.Image
                    # XXX: Doesn't seem to work correctly, converted
                    # image consists of vertical stripes - probably an
                    # issue with order of RGB data?
                    # width, height = pim.size
                    # img = wx.ImageFromBuffer(width, height,
                    # pim.tobytes())
                    pim.save(tif_path)
                except Exception as exception:
                    print("Info:", exception)
                else:
                    bmp_path = tif_path
                # We are done with PIL image now

        return bmp_path, tif_path

    def grab_image(self) -> None:
        """Grab screen shot."""
        if self.use_simple_splash:
            return
        is_wayland = os.getenv("XDG_SESSION_TYPE") == "wayland"
        # Grab a bitmap of the screen area we're going to draw on
        if sys.platform != "darwin" and not is_wayland:
            dc = wx.ScreenDC()
            # Grab from ScreenDC if not Mac OS X or Wayland
            self._buffereddc.Blit(
                0,
                0,
                self.splash_bmp.Size[0],
                self.splash_bmp.Size[1],
                dc,
                self.splash_x,
                self.splash_y,
            )
        elif not isinstance(self.worker.create_tempdir(), Exception):
            # Use screencapture utility under Mac OS X and Wayland
            if sys.platform == "darwin":
                self.grab_image_macos()
            else:
                # Wayland
                self.grab_image_wayland()

    def grab_image_macos(self) -> None:
        """Grab a screenshot on macOS."""
        # Use screencapture utility under Mac OS X
        splashdimensions = (
            self.splash_x,
            self.splash_y,
            self.splash_bmp.Size[0],
            self.splash_bmp.Size[1],
        )
        extra_args = []
        is_mavericks = intlist(platform.mac_ver()[0].split(".")) >= [10, 9]
        if is_mavericks:
            # Under 10.9 we can specify screen region as arguments
            extra_args = ["-R{:.0f},{:.0f},{:.0f},{:.0f}".format(*splashdimensions)]
        extra_args.append("-x")
        screencap = which("screencapture")

        bmp_path = os.path.join(self.worker.tempdir, "screencap.png")
        tif_path = bmp_path
        gamma = 2.04  # somewhat arbitrary gamma value, but works the best for macOS
        if self.worker.exec_cmd(
            screencap,
            [*extra_args, "screencap.png"],
            capture_output=True,
            skip_scripts=True,
            silent=True,
        ) and os.path.isfile(bmp_path):
            result = True
        else:
            result = False
        img = None
        if not result:
            return
        bmp_path, tif_path = self.gamma_correct_image(bmp_path, gamma=gamma)
        if not img:
            img = wx.Image(bmp_path)
        if img.IsOk():
            if wx.VERSION > (3,):
                quality = wx.IMAGE_QUALITY_BICUBIC
            else:
                quality = wx.IMAGE_QUALITY_HIGH
            if is_mavericks and (
                img.Width != self.splash_bmp.Size[0] > 0
                or img.Height != self.splash_bmp.Size[1] > 0
            ):
                # Retina
                img.Rescale(
                    round(img.Width * (self.splash_bmp.Size[0] / float(img.Width))),
                    round(img.Height * (self.splash_bmp.Size[1] / float(img.Height))),
                    quality,
                )

            if (
                not is_mavericks
                and img.Width >= self.splash_x + self.splash_bmp.Size[0]
                and img.Height >= self.splash_y + self.splash_bmp.Size[1]
            ):
                # macOS pre 10.9 or Wayland we have to get the
                # splashscreen region from the full screenshot bitmap
                img = img.GetSubImage(splashdimensions)
            if bmp_path != tif_path:
                # Fallback
                img.GammaCorrect(from_gamma=1.8, to_gamma=gamma)
            bmp = img.ConvertToBitmap()
            self._buffereddc.DrawBitmap(bmp, 0, 0)
        self.worker.wrapup(False)

    def grab_image_wayland(self) -> None:
        """Grab a screenshot on Wayland."""
        # Use screencapture utility under Wayland
        splashdimensions = (
            self.splash_x,
            self.splash_y,
            self.splash_bmp.Size[0],
            self.splash_bmp.Size[1],
        )
        extra_args = []
        geometry = [0, 0, 0, 0]
        # Wayland
        if os.getenv("XDG_CURRENT_DESKTOP", "").split(":")[0] == "KDE":
            extra_args.extend(
                ["--fullscreen", "--background", "--nonotify", "--output"]
            )
            # XXX: Even though the documentation suggests otherwise,
            # spectacle's --background mode still prompts for user
            # interaction to actually take the screenshot...
            screencap = None  # which("spectacle")
        else:
            extra_args.append("-f")
            screencap = which("gnome-screenshot")
        # Determine HiDPI scaling factor
        geometry = self.GetDisplay().Geometry
        bmp_path = os.path.join(self.worker.tempdir, "screencap.png")
        if self.worker.exec_cmd(
            screencap,
            [*extra_args, "screencap.png"],
            capture_output=True,
            skip_scripts=True,
            silent=True,
        ) and os.path.isfile(bmp_path):
            result = True
        else:
            result = False
        img = None
        if not result:
            return
        if not img:
            img = wx.Image(bmp_path)
        if img.IsOk():
            if wx.VERSION > (3,):
                quality = wx.IMAGE_QUALITY_BICUBIC
            else:
                quality = wx.IMAGE_QUALITY_HIGH
            if img.Width != geometry[2] > 0 or img.Height != geometry[3] > 0:
                # Wayland + HiDPI
                img.Rescale(
                    round(img.Width * (geometry[2] / float(img.Width))),
                    round(img.Height * (geometry[3] / float(img.Height))),
                    quality,
                )
            if (
                img.Width >= self.splash_x + self.splash_bmp.Size[0]
                and img.Height >= self.splash_y + self.splash_bmp.Size[1]
            ):
                # Wayland we have to get the
                # splashscreen region from the full screenshot bitmap
                img = img.GetSubImage(splashdimensions)
            bmp = img.ConvertToBitmap()
            self._buffereddc.DrawBitmap(bmp, 0, 0)
        self.worker.wrapup(False)

    def startup(self) -> None:
        """Start the splash screen animation."""
        if sys.platform not in ("darwin", "win32"):
            # Drawing of window shadow can be prevented under some desktop
            # environments that would normally try to draw a shadow by never
            # making the window fully opaque
            endalpha = 254
        else:
            endalpha = 255
        if self.IsShown() and self._alpha < endalpha:
            self._alpha += 15
            self._alpha = min(self._alpha, endalpha)
            self.SetTransparent(self._alpha)
            if sys.platform not in ("darwin", "win32"):
                self.Refresh()
                self.Update()
            wx.CallLater(1, self.startup)
            return
        if (
            self.frame
            < (
                len(self.zoom_scales)
                + len(self.splash_anim)
                + len(self.splash_version_anim)
            )
            - 1
        ):
            self.frame += 1
            self.Refresh()
            self.Update()
            if self.frame < len(self.zoom_scales):
                wx.CallLater(1, self.startup)
            else:
                wx.CallLater(int(1000 / 30.0), self.startup)
            return
        # Give 20 seconds for display & instrument enumeration to run.
        # This should be plenty and will kill the subprocess in case it hangs.
        self.timeout = wx.CallLater(20000, self.worker.abort_subprocess)
        inst_count = len(getcfg("instruments"))
        delayedresult.startWorker(
            self.setup_frame,
            self.worker.enumerate_displays_and_ports,
            wkwargs={
                "enumerate_ports": not FORCE_SKIP_INITIAL_INSTRUMENT_DETECTION
                and (
                    getcfg("enumerate_ports.auto")
                    # Always detect instruments when
                    # there were several instruments
                    # the last time the app was used.
                    # This is actually required under
                    # Win10 1903 or newer because
                    # ordering is not guaranteed
                    # consistent between reboots even
                    # if the connected instruments are
                    # the same.
                    # For consistency's sake, do it under
                    # all platforms.
                    or not inst_count
                    or inst_count > 1
                ),
                "silent": True,
            },
        )

    def setup_frame(self, result: delayedresult.DelayedResult) -> None:
        """Set up the main frame after the splash screen.

        Args:
            result (delayedresult.DelayedResult): The result of the worker
                enumeration.
        """
        if self.timeout.IsRunning():
            self.timeout.Stop()
        self.timeout = None
        try:
            result.get()
        except Exception as exception:
            if hasattr(exception, "originalTraceback"):
                error = exception.originalTraceback
            else:
                error = traceback.format_exc()
            print(error)
            show_result_dialog(UnloggedError(exception))
        verbose_print(lang.getstr("initializing_gui"))
        app = wx.GetApp()
        app.frame = MainFrame(self.worker)
        self.setup_frame_finish(app)

    def setup_frame_finish(self, app: wx.App) -> None:
        """Finish setting up the main frame after the splash screen.

        Args:
            app (wx.App): The wx application instance.
        """
        if self.IsShown() and self._alpha > 0:
            self._alpha -= 15
            self._alpha = max(self._alpha, 0)
            self.SetTransparent(self._alpha)
            if sys.platform not in ("darwin", "win32"):
                self.Refresh()
                self.Update()
            wx.CallLater(1, self.setup_frame_finish, app)
            return
        app.SetTopWindow(app.frame)
        app.frame.listen()
        app.frame.Show()
        app.process_argv(1)
        wx.CallAfter(app.frame.Raise)
        # Check for updates if configured
        if getcfg("update_check"):
            # Give time for the main window to gain focus before checking for
            # update, otherwise the main window may steal the update
            # confirmation dialog's focus which looks weird
            wx.CallAfter(app.frame.app_update_check_handler, None, silent=True)
        else:
            # Check if we need to run instrument setup
            wx.CallAfter(
                app.frame.check_instrument_setup,
                check_donation,
                (app.frame, False),
            )
        # If resources are missing, XRC shows an error dialog which immediately
        # gets hidden when we close ourselves because we are the parent.
        # Hide instead.
        win = app.frame.get_top_window()
        if isinstance(win, wx.Dialog):
            if isinstance(self, wx.Dialog):
                self.EndModal(wx.ID_CANCEL)
            else:
                self.Hide()
        else:
            self.Destroy()

    def OnEraseBackground(self, event: wx.EraseEvent) -> None:  # noqa: N802
        """Handle the erase background event for the splash screen.

        Args:
            event (wx.EraseEvent): The erase event.
        """

    def OnPaint(self, event: wx.PaintEvent) -> None:  # noqa: N802
        """Handle the paint event for the splash screen.

        Args:
            event (wx.PaintEvent): The paint event.
        """
        if sys.platform != "win32":
            # AutoBufferedPaintDCFactory is the magic needed for crisp text
            # rendering in HiDPI mode under OS X and Linux
            cls = wx.AutoBufferedPaintDCFactory
        else:
            cls = wx.BufferedPaintDC
        self.Draw(cls(self))

    def Draw(self, dc: wx.DC) -> None:  # noqa: N802
        """Draw the splash screen.

        Args:
            dc (wx.DC): The device context to draw on.
        """
        # Background
        dc.SetBackgroundMode(wx.BRUSHSTYLE_TRANSPARENT)
        if isinstance(dc, wx.ScreenDC):
            dc.StartDrawingOnTop()
            x, y = self.splash_x, self.splash_y
        else:
            dc.Clear()
            if hasattr(self, "_buffereddc"):
                dc.Blit(
                    0,
                    0,
                    self.splash_bmp.Size[0],
                    self.splash_bmp.Size[1],
                    self._buffereddc,
                    0,
                    0,
                )
            x = y = 0
        if self.frame < len(self.zoom_scales):
            pdc = dc
            bufferbitmap = wx.EmptyBitmap(
                self.splash_bmp.Size[0], self.splash_bmp.Size[1]
            )
            dc = wx.MemoryDC()
            dc.SelectObject(bufferbitmap)
            dc.SetBackgroundMode(wx.BRUSHSTYLE_TRANSPARENT)
        dc.DrawBitmap(self.splash_bmp, x, y)
        # Text
        rect = wx.Rect(
            0, int(self.splash_bmp.Size[1] * 0.75), self.splash_bmp.Size[0], 40
        )
        dc.SetFont(self.GetFont())
        # Version label
        label_str = VERSION_STRING
        dc.SetTextForeground("#101010")
        yoff = 10
        scale = getcfg("app.dpi") / config.get_default_dpi()
        if scale > 1:
            yoff = round(yoff * scale)
        yoff -= 10
        dc.DrawLabel(
            label_str,
            wx.Rect(rect.x, 110 + yoff, rect.width, 32),
            wx.ALIGN_CENTER | wx.ALIGN_TOP,
        )
        dc.SetTextForeground(wx.BLACK)
        dc.DrawLabel(
            label_str,
            wx.Rect(rect.x, 111 + yoff, rect.width, 32),
            wx.ALIGN_CENTER | wx.ALIGN_TOP,
        )
        dc.SetTextForeground("#CCCCCC")
        dc.DrawLabel(
            label_str,
            wx.Rect(rect.x, 112 + yoff, rect.width, 32),
            wx.ALIGN_CENTER | wx.ALIGN_TOP,
        )
        # Message
        dc.SetTextForeground("#101010")
        dc.DrawLabel(
            self._msg,
            wx.Rect(rect.x, rect.y + 2, rect.width, rect.height),
            wx.ALIGN_CENTER | wx.ALIGN_TOP,
        )
        dc.SetTextForeground(wx.BLACK)
        dc.DrawLabel(
            self._msg,
            wx.Rect(rect.x, rect.y + 1, rect.width, rect.height),
            wx.ALIGN_CENTER | wx.ALIGN_TOP,
        )
        dc.SetTextForeground("#CCCCCC")
        dc.DrawLabel(self._msg, rect, wx.ALIGN_CENTER | wx.ALIGN_TOP)
        if self.frame < len(self.zoom_scales):
            # Zoom
            dc.DrawBitmap(self.splash_anim[0], x, y)
            dc.SelectObject(wx.NullBitmap)
            scale = self.zoom_scales[self.frame]
            frame = bufferbitmap.ConvertToImage()
            frame.SetAlphaData(self.splash_alpha)
            if scale < 1:
                frame = frame.Blur(round(1 * (1 - scale)))
            if wx.VERSION > (3,):
                quality = wx.IMAGE_QUALITY_BILINEAR
            else:
                quality = wx.IMAGE_QUALITY_HIGH
            frame.Rescale(
                max(round(self.splash_bmp.Size[0] * scale), 1),
                max(round(self.splash_bmp.Size[1] * scale), 1),
                quality,
            )
            frame.Resize(
                self.splash_bmp.Size,
                (
                    round(self.splash_bmp.Size[0] / 2 - frame.Width / 2),
                    round(self.splash_bmp.Size[1] / 2 - frame.Height / 2),
                ),
            )
            pdc.DrawBitmap(frame.ConvertToBitmap(), x, y)
        else:
            # Animation
            if self.splash_anim:
                dc.DrawBitmap(
                    self.splash_anim[
                        min(
                            self.frame - len(self.zoom_scales),
                            len(self.splash_anim) - 1,
                        )
                    ],
                    x,
                    y,
                )
            if self.frame > len(self.zoom_scales) + len(self.splash_anim) - 1:
                dc.DrawBitmap(
                    self.splash_version_anim[
                        self.frame - len(self.zoom_scales) - len(self.splash_anim)
                    ],
                    x,
                    y,
                )
        if isinstance(dc, wx.ScreenDC):
            dc.EndDrawingOnTop()

    def Pulse(self, msg: None | str = None) -> tuple[bool, bool]:  # noqa: N802
        """Pulse the splash screen with a message.

        Args:
            msg (None | str): The message to display on the splash screen.

        Returns:
            tuple[bool, bool]: A tuple indicating whether the message was set
                and whether the splash screen is shown.
        """
        if msg:
            self._msg = msg
            if self.IsShown():
                self.Refresh()
                self.Update()
        return True, False

    def SetWindowShape(self, *args) -> None:  # noqa: N802
        """Set the window shape to the splash bitmap."""
        r = wx.RegionFromBitmapColour(self.mask_bmp, wx.BLACK)
        self.hasShape = self.SetShape(r)

    UpdatePulse = Pulse


class MeasurementFileCheckSanityDialog(ConfirmDialog):
    """Dialog to check the sanity of a measurement file.

    Args:
        parent (wx.Window): The parent window for the dialog.
        ti3 (CGATS): The measurement file to check.
        suspicious (list): List of suspicious measurements.
        force (bool): Whether to force the dialog to show even if no suspicious
            measurements are found.
    """

    def __init__(
        self, parent: wx.Windows, ti3: CGATS, suspicious: list, force: bool = False
    ) -> None:
        scale = getcfg("app.dpi") / config.get_default_dpi()
        scale = max(scale, 1)
        ConfirmDialog.__init__(
            self,
            parent,
            title=(
                os.path.basename(ti3.filename)
                if ti3.filename
                else lang.getstr("measurement_file.check_sanity")
            ),
            ok=lang.getstr("ok"),
            cancel=lang.getstr("cancel"),
            alt=lang.getstr("invert_selection"),
            bitmap=get_icon(32, "dialog-warning"),
            wrap=120,
        )
        msg_col1 = lang.getstr("warning.suspicious_delta_e")
        msg_col2 = lang.getstr("warning.suspicious_delta_e.info")

        margin = 12

        dlg = self

        dlg.sizer3.Remove(0)  # Remove message textbox
        dlg.message.Destroy()
        dlg.sizer4 = wx.BoxSizer(wx.HORIZONTAL)
        dlg.sizer3.Add(dlg.sizer4)
        dlg.message_col1 = wx.StaticText(dlg, -1, msg_col1)
        dlg.message_col1.Wrap(int(450 * scale))
        dlg.sizer4.Add(dlg.message_col1, flag=wx.RIGHT, border=20)
        dlg.message_col2 = wx.StaticText(dlg, -1, msg_col2)
        dlg.message_col2.Wrap(int(450 * scale))
        dlg.sizer4.Add(dlg.message_col2, flag=wx.LEFT, border=20)

        dlg.Unbind(wx.EVT_BUTTON, dlg.alt)
        dlg.Bind(wx.EVT_BUTTON, dlg.invert_selection_handler, id=dlg.alt.GetId())

        dlg.select_all_btn = wx.Button(dlg.buttonpanel, -1, lang.getstr("deselect_all"))
        dlg.sizer2.Insert(2, dlg.select_all_btn)
        dlg.sizer2.Insert(2, (margin, margin))
        dlg.Bind(wx.EVT_BUTTON, dlg.select_all_handler, id=dlg.select_all_btn.GetId())

        dlg.ti3 = ti3
        dlg.suspicious = suspicious
        dlg.mods = {}
        dlg.force = force

        style = wx.BORDER_SIMPLE if "gtk3" in wx.PlatformInfo else wx.BORDER_THEME
        dlg.grid = CustomGrid(
            dlg, -1, size=(int(940 * scale), int(200 * scale)), style=style
        )
        grid = dlg.grid
        grid.DisableDragRowSize()
        grid.SetCellHighlightPenWidth(0)
        grid.SetCellHighlightROPenWidth(0)
        grid.SetDefaultCellAlignment(wx.ALIGN_CENTER, wx.ALIGN_CENTER)
        grid.SetMargins(0, 0)
        grid.SetRowLabelAlignment(wx.ALIGN_RIGHT, wx.ALIGN_CENTER)
        grid.SetScrollRate(5, 5)
        grid.draw_horizontal_grid_lines = False
        grid.draw_vertical_grid_lines = False
        grid.CreateGrid(0, 15)
        grid.SetColLabelSize(round(self.grid.GetDefaultRowSize() * 2.4))
        dc = wx.MemoryDC(wx.EmptyBitmap(1, 1))
        dc.SetFont(grid.GetLabelFont())
        w, h = dc.GetTextExtent(
            f"99{dlg.ti3.DATA[dlg.ti3.NUMBER_OF_SETS - 1].SAMPLE_ID}"
        )
        grid.SetRowLabelSize(max(w, grid.GetDefaultRowSize()))
        w, h = dc.GetTextExtent("9999999999")
        for i in range(grid.GetNumberCols()):
            if i in (4, 5) or i > 8:
                attr = wx.grid.GridCellAttr()
                attr.SetReadOnly(True)
                grid.SetColAttr(i, attr)
            size = (
                (22 * scale)
                if i == 0
                else self.grid.GetDefaultRowSize()
                if i in (4, 5)
                else w
            )
            grid.SetColSize(i, int(size))
        for i, label in enumerate(
            [
                "",
                "R %",
                "G %",
                "B %",
                "",
                "",
                "X",
                "Y",
                "Z",
                "\u0394E*00\nXYZ A/B",
                "0.5 \u0394E*00\nRGB A/B",
                "\u0394E*00\nRGB-XYZ",
                "\u0394L*00\nRGB-XYZ",
                "\u0394C*00\nRGB-XYZ",
                "\u0394H*00\nRGB-XYZ",
            ]
        ):
            grid.SetColLabelValue(i, label)
        attr = wx.grid.GridCellAttr()
        # attr.SetReadOnly(True)
        attr.SetRenderer(CustomCellBoolRenderer())
        grid.SetColAttr(0, attr)
        font = grid.GetDefaultCellFont()
        if font.PointSize > 11:
            font.PointSize = 11
            grid.SetDefaultCellFont(font)
        grid.DisableDragColSize()
        grid.EnableGridLines(False)

        black = ti3.queryi1({"RGB_R": 0, "RGB_G": 0, "RGB_B": 0})
        black = black["XYZ_X"], black["XYZ_Y"], black["XYZ_Z"] if black else black
        dlg.black = black
        white = ti3.queryi1({"RGB_R": 100, "RGB_G": 100, "RGB_B": 100})
        white = white["XYZ_X"], white["XYZ_Y"], white["XYZ_Z"] if white else white
        dlg.white = white
        dlg.suspicious_items = []
        grid.BeginBatch()
        for _i, (
            prev,
            item,
            delta,
            sRGB_delta,  # noqa: N806
            prev_delta_to_sRGB,  # noqa: N806
            delta_to_sRGB,  # noqa: N806
        ) in enumerate(suspicious):
            for cur in (prev, item):
                if not cur or cur in dlg.suspicious_items:
                    continue
                dlg.suspicious_items.append(cur)
                grid.AppendRows(1)
                row = grid.GetNumberRows() - 1
                grid.SetRowLabelValue(row, f"{cur.SAMPLE_ID:.0f}")
                RGB = []  # noqa: N806
                for k, label in enumerate("RGB"):
                    value = cur[f"RGB_{label}"]
                    grid.SetCellValue(row, 1 + k, f"{value:.4f}")
                    RGB.append(value)
                XYZ = []  # noqa: N806
                for k, label in enumerate("XYZ"):
                    value = cur[f"XYZ_{label}"]
                    grid.SetCellValue(row, 6 + k, f"{value:.4f}")
                    XYZ.append(value)
                if cur is prev:
                    dlg.update_row(row, RGB, XYZ, None, None, prev_delta_to_sRGB)
                else:
                    dlg.update_row(row, RGB, XYZ, delta, sRGB_delta, delta_to_sRGB)
        grid.EndBatch()

        grid.Bind(wx.EVT_KEY_DOWN, dlg.key_handler)
        grid.Bind(wx.grid.EVT_GRID_CELL_CHANGE, dlg.cell_change_handler)
        grid.Bind(wx.grid.EVT_GRID_CELL_LEFT_CLICK, dlg.cell_click_handler)

        dlg.sizer3.Add(grid, 1, flag=wx.TOP | wx.ALIGN_LEFT, border=12)

        dlg.buttonpanel.Layout()
        dlg.sizer0.SetSizeHints(dlg)
        dlg.sizer0.Layout()

        # This workaround is needed to update cell colours
        grid.SelectAll()
        grid.ClearSelection()

        dlg.Center()

    def cell_change_handler(self, event: wx.grid.GridEvent) -> None:
        """Handle cell change events in the grid.

        Args:
            event (wx.grid.GridEvent): The grid event that triggered this method.

        Raises:
            ValueError: If the value entered in the cell is invalid.
        """
        dlg = self
        grid = dlg.grid
        if event.Col <= 0:
            dlg.check_select_status()
            return
        item = dlg.suspicious_items[event.Row]
        label = "_RGB__XYZ"[event.Col]
        label = f"RGB_{label}" if event.Col < 6 else f"XYZ_{label}"
        strval = "0{}".format(grid.GetCellValue(event.Row, event.Col).replace(",", "."))
        try:
            value = float(strval)
            if (label[:3] == "RGB" or label == "XYZ_Y") and value > 100:
                raise ValueError(f"Value {value!r} is invalid")
            if value < 0:
                raise ValueError(f"Negative value {value!r} is invalid")
        except ValueError:
            wx.Bell()
            strval = f"{item[label]:.4f}"
            if "." in strval:
                strval = strval.rstrip("0").rstrip(".")
            grid.SetCellValue(
                event.Row, event.Col, re.sub(r"^0+(?!\.)", "", strval) or "0"
            )
            return

        grid.SetCellValue(event.Row, event.Col, re.sub(r"^0+(?!\.)", "", strval) or "0")
        RGB = [float(grid.GetCellValue(event.Row, i)) for i in (1, 2, 3)]  # noqa: N806
        XYZ = [float(grid.GetCellValue(event.Row, i)) for i in (6, 7, 8)]  # noqa: N806
        # Update row
        (
            sRGBLab,  # noqa: N806
            Lab,  # noqa: N806
            delta_to_sRGB,  # noqa: N806
            criteria1,
            debuginfo,
        ) = check_ti3_criteria1(RGB, XYZ, dlg.black, dlg.white, print_debuginfo=True)
        if grid.GetCellValue(event.Row, 9):
            prev = dlg.suspicious_items[event.Row - 1]
            prev_RGB = prev["RGB_R"], prev["RGB_G"], prev["RGB_B"]  # noqa: N806
            prev_XYZ = prev["XYZ_X"], prev["XYZ_Y"], prev["XYZ_Z"]  # noqa: N806
            (
                prev_sRGBLab,  # noqa: N806
                prev_Lab,  # noqa: N806
                prev_delta_to_sRGB,  # noqa: N806
                prev_criteria1,
                prev_debuginfo,
            ) = check_ti3_criteria1(
                prev_RGB, prev_XYZ, dlg.black, dlg.white, print_debuginfo=False
            )
            (delta, sRGB_delta, criteria2) = check_ti3_criteria2(  # noqa: N806
                prev_Lab, Lab, prev_sRGBLab, sRGBLab, prev_RGB, RGB
            )
        else:
            delta, sRGB_delta = (None,) * 2  # noqa: N806
        dlg.update_row(event.Row, RGB, XYZ, delta, sRGB_delta, delta_to_sRGB)

        if item[label] != value:
            if not dlg.mods.get(event.Row):
                dlg.mods[event.Row] = {}
            dlg.mods[event.Row][label] = value

        dlg.ok.Enable(not dlg.force or bool(dlg.mods))

        # This workaround is needed to update cell colours
        cells = grid.GetSelection()
        grid.SelectAll()
        grid.ClearSelection()
        for row, col in cells:
            grid.SelectBlock(row, col, row, col, True)

    def cell_click_handler(self, event: wx.grid.GridEvent) -> None:
        """Handle cell click events in the grid.

        Args:
            event (wx.grid.GridEvent): The grid event that triggered this
                method.
        """
        if event.Col != 0:
            event.Skip()
            return

        value = "" if self.grid.GetCellValue(event.Row, event.Col) else "1"
        self.grid.SetCellValue(event.Row, event.Col, value)
        self.check_select_status()
        event.Skip()

    def check_select_status(
        self,
        has_false_values: None | bool = None,
        has_true_values: None | bool = None,
    ) -> None:
        """Check the selection status of the grid and update the dialog buttons.

        Args:
            has_false_values (None | bool): If True, there are rows with false
                values.
            has_true_values (None | bool): If True, there are rows with true
                values.
        """
        dlg = self
        if None in (has_false_values, has_true_values):
            for index in range(dlg.grid.GetNumberRows()):
                if dlg.grid.GetCellValue(index, 0) != "1":
                    has_false_values = True
                else:
                    has_true_values = True
        dlg.ok.Enable(has_false_values or not self.force or bool(dlg.mods))
        if has_true_values:
            dlg.select_all_btn.SetLabel(lang.getstr("deselect_all"))
        else:
            dlg.select_all_btn.SetLabel(lang.getstr("select_all"))

    def invert_selection_handler(self, event: wx.Event) -> None:
        """Invert the selection of rows in the grid.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        dlg = self
        has_false_values = False
        has_true_values = False
        for index in range(dlg.grid.GetNumberRows()):
            if dlg.grid.GetCellValue(index, 0) == "1":
                value = ""
                has_false_values = True
            else:
                value = "1"
                has_true_values = True
            dlg.grid.SetCellValue(index, 0, value)
        self.check_select_status(has_false_values, has_true_values)

    def key_handler(self, event: wx.Event) -> None:
        """Handle key events in the grid.

        Args:
            event (wx.Event): The key event that triggered this method.
        """
        dlg = self
        if event.KeyCode != wx.WXK_SPACE:
            event.Skip()
            return
        if dlg.grid.GridCursorCol != 0:
            return
        dlg.cell_click_handler(
            CustomGridCellEvent(
                wx.grid.EVT_GRID_CELL_CHANGE.evtType[0],
                dlg.grid,
                dlg.grid.GridCursorRow,
                dlg.grid.GridCursorCol,
            )
        )

    def mark_cell(self, row: int, col: int, ok: bool = False) -> None:
        """Mark a cell in the grid with a bold font and red text if not ok.

        Args:
            row (int): The row index of the cell to mark.
            col (int): The column index of the cell to mark.
            ok (bool, optional): If True, the cell will be marked as ok (normal
                font and color).
        """
        grid = self.grid
        font = grid.GetCellFont(row, col)
        font.SetWeight(wx.FONTWEIGHT_NORMAL if ok else wx.FONTWEIGHT_BOLD)
        grid.SetCellFont(row, col, font)
        grid.SetCellTextColour(
            row, col, grid.GetDefaultCellTextColour() if ok else wx.Colour(204, 0, 0)
        )

    def select_all_handler(self, event: wx.Event) -> None:
        """Toggle selection of all rows in the grid.

        Args:
            event (wx.Event): The event that triggered this method.
        """
        dlg = self
        if dlg.select_all_btn.GetLabel() == lang.getstr("select_all"):
            value = "1"
        else:
            value = ""
        for index in range(dlg.grid.GetNumberRows()):
            dlg.grid.SetCellValue(index, 0, value)
        self.check_select_status(not value, value)

    def update_row(
        self,
        row: int,
        RGB: list[float],  # noqa: N803
        XYZ: list[float],  # noqa: N803
        delta: None | dict,
        sRGB_delta: None | dict,  # noqa: N803
        delta_to_sRGB: dict,  # noqa: N803
    ) -> None:
        """Update a grid row with RGB, XYZ, and delta and delta_to_sRGB values.

        Args:
            row (int): The row index to update.
            RGB (list): A list of RGB values as floats in the range [0, 100].
            XYZ (list): A list of XYZ values as floats in the range [0, 100].
            delta (None | dict): A dictionary containing delta values or None.
            sRGB_delta (None | dict): A dictionary containing sRGB delta values
                or None.
            delta_to_sRGB (dict): A dictionary containing delta values to sRGB.
                It should contain keys 'E', 'L', 'C', 'H', and their
                corresponding '_ok' values indicating whether the deltas
                are within acceptable limits.
        """
        dlg = self
        grid = dlg.grid
        # XXX: Careful when rounding floats!
        # Incorrect: int(round(50 * 2.55)) = 127 (127.499999)
        # Correct: int(round(50 / 100.0 * 255)) = 128 (127.5)
        RGB255 = [round(v / 100.0 * 255) for v in RGB]  # noqa: N806
        dlg.grid.SetCellBackgroundColour(row, 4, wx.Colour(*RGB255))
        if dlg.white:
            XYZ = colormath.adapt(XYZ[0], XYZ[1], XYZ[2], dlg.white, "D65")  # noqa: N806
        RGB255 = [  # noqa: N806
            round(v)
            for v in colormath.XYZ2RGB(
                XYZ[0] / 100.0, XYZ[1] / 100.0, XYZ[2] / 100.0, scale=255
            )
        ]
        dlg.grid.SetCellBackgroundColour(row, 5, wx.Colour(*RGB255))
        grid.SetCellValue(
            row,
            0,
            (
                "1"
                if (not delta or (delta["E_ok"] and delta["L_ok"]))
                and delta_to_sRGB["ok"]
                else ""
            ),
        )
        for col in range(3):
            dlg.mark_cell(
                row,
                6 + col,
                (not delta or (delta["E_ok"] and (delta["L_ok"] or col != 1)))
                and delta_to_sRGB["ok"],
            )
        if delta:
            grid.SetCellValue(row, 9, f"{delta['E']:.2f}")
            dlg.mark_cell(row, 9, delta["E_ok"])
            if sRGB_delta:
                grid.SetCellValue(row, 10, f"{sRGB_delta['E']:.2f}")
        for col, elch in enumerate("ELCH"):
            grid.SetCellValue(row, 11 + col, f"{delta_to_sRGB[elch]:.2f}")
            dlg.mark_cell(row, 11 + col, delta_to_sRGB[f"{elch}_ok"])


def main() -> None:
    """Main function to start the application."""
    initcfg()
    lang.init()
    # Startup messages
    verbose_print(lang.getstr("startup"))
    if sys.platform != "darwin":
        if not AUTOSTART:
            print(lang.getstr("warning.autostart_system"))
        if not AUTOSTART_HOME:
            print(lang.getstr("warning.autostart_user"))
    app = BaseApp(0)  # Don't redirect stdin/stdout
    app.TopWindow = StartupFrame()
    app.MainLoop()


if __name__ == "__main__":
    main()
