"""GUI for creating and editing test charts with wxPython.

It supports features such as patch customization, color space adjustments, file
import/export, and 3D visualization. The editor allows users to generate test
charts for color calibration and profiling tasks with various configuration
options.
"""

import csv
import math
import os
import re
import shutil
import sys
import time

from DisplayCAL.argyll import check_set_argyll_bin

if sys.platform == "win32":
    from win32 import win32file

from DisplayCAL import (
    argyll_rgb2xyz,
    colormath,
    config,
    floatspin,
    imfile,
)
from DisplayCAL import localization as lang
from DisplayCAL.argyll_cgats import ti3_to_ti1, verify_cgats
from DisplayCAL.cgats import (
    CGATS,
    CGATSError,
    CGATSKeyError,
    rpad,
    sort_by_rec709_luma,
    sort_by_rgb,
    sort_by_rgb_sum,
    stable_sort_by_l,
)
from DisplayCAL.config import (
    DEFAULTS,
    get_bitmap,
    get_data_path,
    get_display_name,
    get_icon,
    get_total_patches,
    get_verified_path,
    getcfg,
    hascfg,
    setcfg,
    writecfg,
)
from DisplayCAL.debughelpers import handle_error
from DisplayCAL.icc_profile import (
    ICCProfile,
    ICCProfileInvalidError,
    NamedColor2Type,
)
from DisplayCAL.meta import NAME as APPNAME
from DisplayCAL.options import DEBUG, TC_USE_ALTERNATE_PREVIEW, TEST, VERBOSE
from DisplayCAL.util_dict import swap_dict_keys_values
from DisplayCAL.util_os import is_superuser, launch_file, waccess
from DisplayCAL.worker import (
    Error,
    Worker,
    check_file_isfile,
    get_argyll_util,
    get_current_profile_path,
    show_result_dialog,
)
from DisplayCAL.wx_addons import CustomEvent, wx
from DisplayCAL.wx_measure_frame import get_default_size
from DisplayCAL.wx_windows import (
    BaseApp,
    BaseFrame,
    ConfirmDialog,
    CustomGrid,
    FileBrowseBitmapButtonWithChoiceHistory,
    FileDrop,
    get_gradient_panel,
)


class TestchartEditor(BaseFrame):
    """Testchart editor window.

    Args:
        parent (wx.Window, optional): Parent window.
        id (int, optional): Window identifier. Defaults to -1.
        path (str, optional): Path to a test chart file to load. If None,
            an empty test chart is created.
        cfg (str, optional): Configuration file name. Defaults to
            "testchart.file".
        parent_set_chart_methodname (str, optional): Method name in the parent
            to set the test chart. Defaults to "set_testchart".
        setup (bool, optional): Whether to set up the editor immediately.
            Defaults to True.
    """

    def __init__(
        self,
        parent=None,
        id=-1,  # noqa: A002
        path=None,
        cfg="testchart.file",
        parent_set_chart_methodname="set_testchart",
        setup=True,
    ):
        BaseFrame.__init__(
            self, parent, id, lang.getstr("testchart.edit"), name="tcgen"
        )
        self.SetIcons(
            config.get_icon_bundle([256, 48, 32, 16], f"{APPNAME}-testchart-editor")
        )
        self.Bind(wx.EVT_CLOSE, self.tc_close_handler)

        self.tc_algos_ab = {
            "": lang.getstr("tc.ofp"),
            "t": lang.getstr("tc.t"),
            "r": lang.getstr("tc.r"),
            "R": lang.getstr("tc.R"),
            "q": lang.getstr("tc.q"),
            "i": lang.getstr("tc.i"),
            "I": lang.getstr("tc.I"),
        }

        self.cfg = cfg
        self.parent_set_chart_methodname = parent_set_chart_methodname

        if setup:
            self.setup(path)

    def setup(self, path=None):
        """Set up the testchart editor.

        Args:
            path (str, optional): Path to a test chart file to load. If None,
                an empty test chart is created.
        """
        self.worker = Worker(self)
        self.worker.set_argyll_version("targen")

        if self.worker.argyll_version >= [1, 1, 0]:
            self.tc_algos_ab["Q"] = lang.getstr("tc.Q")

        self.tc_algos_ba = swap_dict_keys_values(self.tc_algos_ab)

        self.label_b2a = {
            "R %": "RGB_R",
            "G %": "RGB_G",
            "B %": "RGB_B",
            "X": "XYZ_X",
            "Y": "XYZ_Y",
            "Z": "XYZ_Z",
        }

        self.droptarget = FileDrop(self)
        self.droptarget.drophandlers = {
            ".cgats": self.ti1_drop_handler,
            ".cie": self.tc_drop_ti3_handler,
            ".csv": self.csv_drop_handler,
            ".gam": self.tc_drop_ti3_handler,
            ".icc": self.ti1_drop_handler,
            ".icm": self.ti1_drop_handler,
            ".jpg": self.tc_drop_ti3_handler,
            ".jpeg": self.tc_drop_ti3_handler,
            ".png": self.tc_drop_ti3_handler,
            ".tif": self.tc_drop_ti3_handler,
            ".tiff": self.tc_drop_ti3_handler,
            ".ti1": self.ti1_drop_handler,
            ".ti3": self.ti1_drop_handler,
            ".txt": self.ti1_drop_handler,
        }

        scale = getcfg("app.dpi") / config.get_default_dpi()
        scale = max(scale, 1)

        if TC_USE_ALTERNATE_PREVIEW:
            # splitter
            splitter = self.splitter = wx.SplitterWindow(
                self, -1, style=wx.SP_LIVE_UPDATE | wx.SP_3DSASH
            )
            if wx.VERSION < (2, 9):
                self.Bind(
                    wx.EVT_SPLITTER_SASH_POS_CHANGED, self.tc_sash_handler, splitter
                )
                self.Bind(
                    wx.EVT_SPLITTER_SASH_POS_CHANGING, self.tc_sash_handler, splitter
                )

            p1 = wx.Panel(splitter)
            p1.sizer = wx.BoxSizer(wx.VERTICAL)
            p1.SetSizer(p1.sizer)

            p2 = wx.Panel(splitter)
            # Setting a droptarget seems to cause crash when destroying
            # p2.SetDropTarget(self.droptarget)
            p2.sizer = wx.BoxSizer(wx.VERTICAL)
            p2.SetSizer(p2.sizer)

            splitter.SetMinimumPaneSize(23)
            # splitter end

            panel = self.panel = p1
        else:
            panel = self.panel = wx.Panel(self)
        panel.SetDropTarget(self.droptarget)

        self.sizer = wx.BoxSizer(wx.VERTICAL)
        panel.SetSizer(self.sizer)

        border = 4

        sizer = wx.FlexGridSizer(0, 4, 0, 0)
        self.sizer.Add(sizer, flag=(wx.ALL & ~wx.BOTTOM), border=12)

        # white patches
        sizer.Add(
            wx.StaticText(panel, -1, lang.getstr("tc.white")),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.tc_white_patches = wx.SpinCtrl(
            panel, -1, size=(100 * scale, -1), min=0, name="tc_white_patches"
        )
        self.Bind(
            wx.EVT_TEXT, self.tc_white_patches_handler, id=self.tc_white_patches.GetId()
        )
        sizer.Add(
            self.tc_white_patches, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )

        # single channel patches
        sizer.Add(
            wx.StaticText(panel, -1, lang.getstr("tc.single")),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT,
            border=border,
        )
        self.tc_single_channel_patches = wx.SpinCtrl(
            panel,
            -1,
            size=(100 * scale, -1),
            min=0,
            max=256,
            name="tc_single_channel_patches",
        )
        self.tc_single_channel_patches.Bind(
            wx.EVT_KILL_FOCUS, self.tc_single_channel_patches_handler
        )
        self.Bind(
            wx.EVT_SPINCTRL,
            self.tc_single_channel_patches_handler,
            id=self.tc_single_channel_patches.GetId(),
        )
        hsizer = wx.BoxSizer(wx.HORIZONTAL)
        sizer.Add(hsizer)
        hsizer.Add(
            self.tc_single_channel_patches,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        hsizer.Add(
            wx.StaticText(panel, -1, lang.getstr("tc.single.perchannel")),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )

        # black patches
        if self.worker.argyll_version >= [1, 6]:
            hsizer.Add(
                wx.StaticText(panel, -1, lang.getstr("tc.black")),
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )
            self.tc_black_patches = wx.SpinCtrl(
                panel, -1, size=(100 * scale, -1), min=0, name="tc_black_patches"
            )
            self.Bind(
                wx.EVT_TEXT,
                self.tc_black_patches_handler,
                id=self.tc_black_patches.GetId(),
            )
            hsizer.Add(
                self.tc_black_patches,
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )

        # gray axis patches
        sizer.Add(
            wx.StaticText(panel, -1, lang.getstr("tc.gray")),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.tc_gray_patches = wx.SpinCtrl(
            panel, -1, size=(100 * scale, -1), min=0, max=256, name="tc_gray_patches"
        )
        self.tc_gray_patches.Bind(wx.EVT_KILL_FOCUS, self.tc_gray_handler)
        self.Bind(
            wx.EVT_SPINCTRL, self.tc_gray_handler, id=self.tc_gray_patches.GetId()
        )
        sizer.Add(
            self.tc_gray_patches, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )

        # multidim steps
        sizer.Add(
            wx.StaticText(panel, -1, lang.getstr("tc.multidim")),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT,
            border=border,
        )
        hsizer = wx.BoxSizer(wx.HORIZONTAL)
        sizer.Add(hsizer)
        self.tc_multi_steps = wx.SpinCtrl(
            panel, -1, size=(100 * scale, -1), min=0, max=21, name="tc_multi_steps"
        )  # 16 multi dim steps = 4096 patches
        self.tc_multi_steps.Bind(wx.EVT_KILL_FOCUS, self.tc_multi_steps_handler)
        self.Bind(
            wx.EVT_SPINCTRL, self.tc_multi_steps_handler, id=self.tc_multi_steps.GetId()
        )
        hsizer.Add(
            self.tc_multi_steps, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )
        if self.worker.argyll_version >= [1, 6, 0]:
            self.tc_multi_bcc_cb = wx.CheckBox(panel, -1, lang.getstr("centered"))
            self.tc_multi_bcc_cb.Bind(wx.EVT_CHECKBOX, self.tc_multi_bcc_cb_handler)
            hsizer.Add(
                self.tc_multi_bcc_cb,
                flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )
        self.tc_multi_patches = wx.StaticText(panel, -1, "", name="tc_multi_patches")
        hsizer.Add(
            self.tc_multi_patches, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )

        # full spread patches
        sizer.Add(
            wx.StaticText(panel, -1, lang.getstr("tc.fullspread")),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.tc_fullspread_patches = wx.SpinCtrl(
            panel,
            -1,
            size=(100 * scale, -1),
            min=0,
            max=9999,
            name="tc_fullspread_patches",
        )
        self.Bind(
            wx.EVT_TEXT,
            self.tc_fullspread_handler,
            id=self.tc_fullspread_patches.GetId(),
        )
        sizer.Add(
            self.tc_fullspread_patches,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )

        # algo
        algos = list(self.tc_algos_ab.values())
        algos.sort()
        sizer.Add(
            wx.StaticText(panel, -1, lang.getstr("tc.algo")),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT,
            border=border,
        )
        self.tc_algo = wx.Choice(panel, -1, choices=algos, name="tc_algo")
        self.tc_algo.Disable()
        self.Bind(wx.EVT_CHOICE, self.tc_algo_handler, id=self.tc_algo.GetId())
        sizer.Add(self.tc_algo, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border)

        # adaption
        hsizer = wx.BoxSizer(wx.HORIZONTAL)
        sizer.Add(hsizer, 1, flag=wx.EXPAND)
        hsizer.Add(
            wx.StaticText(panel, -1, lang.getstr("tc.adaption")),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.tc_adaption_slider = wx.Slider(
            panel, -1, 0, 0, 100, size=(64 * scale, -1), name="tc_adaption_slider"
        )
        self.tc_adaption_slider.Disable()
        self.Bind(
            wx.EVT_SLIDER, self.tc_adaption_handler, id=self.tc_adaption_slider.GetId()
        )
        hsizer.Add(self.tc_adaption_slider, flag=wx.ALIGN_CENTER_VERTICAL)
        self.tc_adaption_intctrl = wx.SpinCtrl(
            panel,
            -1,
            size=(100 * scale, -1),
            min=0,
            max=100,
            name="tc_adaption_intctrl",
        )
        self.tc_adaption_intctrl.Disable()
        self.Bind(
            wx.EVT_TEXT, self.tc_adaption_handler, id=self.tc_adaption_intctrl.GetId()
        )
        sizer.Add(
            self.tc_adaption_intctrl,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )

        hsizer = wx.GridSizer(0, 2, 0, 0)
        sizer.Add(hsizer, 1, flag=wx.EXPAND)
        hsizer.Add(
            wx.StaticText(panel, -1, "%"),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        hsizer.Add(
            wx.StaticText(panel, -1, lang.getstr("tc.angle")),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT,
            border=border,
        )

        # angle
        hsizer = wx.BoxSizer(wx.HORIZONTAL)
        sizer.Add(hsizer, 1, flag=wx.EXPAND)
        self.tc_angle_slider = wx.Slider(
            panel, -1, 0, 0, 5000, size=(128 * scale, -1), name="tc_angle_slider"
        )
        self.tc_angle_slider.Disable()
        self.Bind(wx.EVT_SLIDER, self.tc_angle_handler, id=self.tc_angle_slider.GetId())
        hsizer.Add(self.tc_angle_slider, flag=wx.ALIGN_CENTER_VERTICAL)
        self.tc_angle_intctrl = wx.SpinCtrl(
            panel, -1, size=(110 * scale, -1), min=0, max=5000, name="tc_angle_intctrl"
        )
        self.tc_angle_intctrl.Disable()
        self.Bind(wx.EVT_TEXT, self.tc_angle_handler, id=self.tc_angle_intctrl.GetId())
        hsizer.Add(
            self.tc_angle_intctrl, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )

        # gamma
        if self.worker.argyll_version == [
            1,
            1,
            "RC2",
        ] or self.worker.argyll_version >= [1, 1]:
            sizer.Add(
                wx.StaticText(panel, -1, lang.getstr("trc.gamma")),
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )
            self.tc_gamma_floatctrl = floatspin.FloatSpin(
                panel,
                -1,
                size=(100 * scale, -1),
                min_val=0.0,
                max_val=9.9,
                increment=0.05,
                digits=2,
                name="tc_gamma_floatctrl",
            )
            self.Bind(
                floatspin.EVT_FLOATSPIN,
                self.tc_gamma_handler,
                id=self.tc_gamma_floatctrl.GetId(),
            )
            sizer.Add(
                self.tc_gamma_floatctrl,
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )

        # neutral axis emphasis
        if self.worker.argyll_version >= [1, 3, 3]:
            sizer.Add(
                wx.StaticText(panel, -1, lang.getstr("tc.neutral_axis_emphasis")),
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT,
                border=border,
            )
            hsizer = wx.BoxSizer(wx.HORIZONTAL)
            sizer.Add(hsizer, 1, flag=wx.EXPAND)
            self.tc_neutral_axis_emphasis_slider = wx.Slider(
                panel,
                -1,
                0,
                0,
                100,
                size=(64 * scale, -1),
                name="tc_neutral_axis_emphasis_slider",
            )
            self.tc_neutral_axis_emphasis_slider.Disable()
            self.Bind(
                wx.EVT_SLIDER,
                self.tc_neutral_axis_emphasis_handler,
                id=self.tc_neutral_axis_emphasis_slider.GetId(),
            )
            hsizer.Add(
                self.tc_neutral_axis_emphasis_slider, flag=wx.ALIGN_CENTER_VERTICAL
            )
            self.tc_neutral_axis_emphasis_intctrl = wx.SpinCtrl(
                panel,
                -1,
                size=(100 * scale, -1),
                min=0,
                max=100,
                name="tc_neutral_axis_emphasis_intctrl",
            )
            self.tc_neutral_axis_emphasis_intctrl.Disable()
            self.Bind(
                wx.EVT_TEXT,
                self.tc_neutral_axis_emphasis_handler,
                id=self.tc_neutral_axis_emphasis_intctrl.GetId(),
            )
            hsizer.Add(
                self.tc_neutral_axis_emphasis_intctrl,
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )
            hsizer.Add(
                wx.StaticText(panel, -1, "%"),
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )

        # dark patch emphasis
        if self.worker.argyll_version >= [1, 6, 2]:
            hsizer.Add(
                wx.StaticText(panel, -1, lang.getstr("tc.dark_emphasis")),
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )
            self.tc_dark_emphasis_slider = wx.Slider(
                panel,
                -1,
                0,
                0,
                100,
                size=(64 * scale, -1),
                name="tc_dark_emphasis_slider",
            )
            self.tc_dark_emphasis_slider.Disable()
            self.Bind(
                wx.EVT_SLIDER,
                self.tc_dark_emphasis_handler,
                id=self.tc_dark_emphasis_slider.GetId(),
            )
            hsizer.Add(self.tc_dark_emphasis_slider, flag=wx.ALIGN_CENTER_VERTICAL)
            self.tc_dark_emphasis_intctrl = wx.SpinCtrl(
                panel,
                -1,
                size=(100 * scale, -1),
                min=0,
                max=100,
                name="tc_dark_emphasis_intctrl",
            )
            self.tc_dark_emphasis_intctrl.Disable()
            self.Bind(
                wx.EVT_TEXT,
                self.tc_dark_emphasis_handler,
                id=self.tc_dark_emphasis_intctrl.GetId(),
            )
            hsizer.Add(
                self.tc_dark_emphasis_intctrl,
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )
            hsizer.Add(
                wx.StaticText(panel, -1, "%"),
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )

        # precond profile
        hsizer = wx.BoxSizer(wx.HORIZONTAL)
        self.sizer.Add(
            hsizer, flag=wx.ALL & ~(wx.BOTTOM | wx.TOP) | wx.EXPAND, border=12
        )
        self.tc_precond = wx.CheckBox(
            panel, -1, lang.getstr("tc.precond"), name="tc_precond"
        )
        self.tc_precond.Disable()
        self.Bind(wx.EVT_CHECKBOX, self.tc_precond_handler, id=self.tc_precond.GetId())
        hsizer.Add(
            self.tc_precond, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )
        self.tc_precond_profile = FileBrowseBitmapButtonWithChoiceHistory(
            panel,
            -1,
            toolTip=lang.getstr("tc.precond"),
            dialogTitle=lang.getstr("tc.precond"),
            fileMask=lang.getstr("filetype.icc_mpp") + "|*.icc;*.icm;*.mpp",
            changeCallback=self.tc_precond_profile_handler,
            history=get_data_path("ref", r"\.(icm|icc)$"),
        )
        self.tc_precond_profile.SetMaxFontSize(11)
        hsizer.Add(
            self.tc_precond_profile,
            1,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )

        self.tc_precond_profile_current_btn = wx.Button(
            panel, -1, lang.getstr("profile.current"), name="tc_precond_profile_current"
        )
        self.Bind(
            wx.EVT_BUTTON,
            self.tc_precond_profile_current_ctrl_handler,
            id=self.tc_precond_profile_current_btn.GetId(),
        )
        hsizer.Add(
            self.tc_precond_profile_current_btn,
            0,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.precond_droptarget = FileDrop(self)
        self.precond_droptarget.drophandlers = {
            ".icc": self.precond_profile_drop_handler,
            ".icm": self.precond_profile_drop_handler,
        }
        self.tc_precond_profile.SetDropTarget(self.precond_droptarget)

        # limit samples to lab sphere
        hsizer = wx.BoxSizer(wx.HORIZONTAL)
        self.sizer.Add(hsizer, flag=wx.ALL & ~(wx.BOTTOM | wx.TOP), border=12)
        self.tc_filter = wx.CheckBox(
            panel, -1, lang.getstr("tc.limit.sphere"), name="tc_filter"
        )
        self.Bind(wx.EVT_CHECKBOX, self.tc_filter_handler, id=self.tc_filter.GetId())
        hsizer.Add(
            self.tc_filter, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )
        # L
        hsizer.Add(
            wx.StaticText(panel, -1, "L"),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.tc_filter_L = wx.SpinCtrl(
            panel,
            -1,
            initial=50,
            size=(100 * scale, -1),
            min=0,
            max=100,
            name="tc_filter_L",
        )
        self.Bind(wx.EVT_TEXT, self.tc_filter_handler, id=self.tc_filter_L.GetId())
        hsizer.Add(
            self.tc_filter_L, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )
        # a
        hsizer.Add(
            wx.StaticText(panel, -1, "a"),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.tc_filter_a = wx.SpinCtrl(
            panel,
            -1,
            initial=0,
            size=(100 * scale, -1),
            min=-128,
            max=127,
            name="tc_filter_a",
        )
        self.Bind(wx.EVT_TEXT, self.tc_filter_handler, id=self.tc_filter_a.GetId())
        hsizer.Add(
            self.tc_filter_a, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )
        # b
        hsizer.Add(
            wx.StaticText(panel, -1, "b"),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.tc_filter_b = wx.SpinCtrl(
            panel,
            -1,
            initial=0,
            size=(100 * scale, -1),
            min=-128,
            max=127,
            name="tc_filter_b",
        )
        self.Bind(wx.EVT_TEXT, self.tc_filter_handler, id=self.tc_filter_b.GetId())
        hsizer.Add(
            self.tc_filter_b, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )
        # radius
        hsizer.Add(
            wx.StaticText(panel, -1, lang.getstr("tc.limit.sphere_radius")),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.tc_filter_rad = wx.SpinCtrl(
            panel,
            -1,
            initial=255,
            size=(100 * scale, -1),
            min=1,
            max=255,
            name="tc_filter_rad",
        )
        self.Bind(wx.EVT_TEXT, self.tc_filter_handler, id=self.tc_filter_rad.GetId())
        hsizer.Add(
            self.tc_filter_rad, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )

        # diagnostic VRML files
        hsizer = wx.BoxSizer(wx.HORIZONTAL)
        self.sizer.Add(hsizer, flag=wx.ALL & ~(wx.BOTTOM | wx.TOP), border=12 + border)

        self.vrml_save_as_btn = wx.BitmapButton(panel, -1, get_icon(16, "3D"))
        if sys.platform == "darwin":
            # Work-around bitmap cutoff on left and right side
            w = self.vrml_save_as_btn.Size[0] + 4
        else:
            w = -1
        self.vrml_save_as_btn.MinSize = (w, -1)
        self.vrml_save_as_btn.SetToolTipString(lang.getstr("tc.3d"))
        self.vrml_save_as_btn.Disable()
        self.Bind(wx.EVT_BUTTON, self.tc_view_3d, id=self.vrml_save_as_btn.GetId())
        self.vrml_save_as_btn.Bind(wx.EVT_CONTEXT_MENU, self.view_3d_format_popup)
        hsizer.Add(
            self.vrml_save_as_btn,
            flag=wx.TOP | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL,
            border=border * 2,
        )
        hsizer.Add((1, 1))
        self.view_3d_format_btn = wx.BitmapButton(
            panel, -1, get_bitmap("theme/dropdown-arrow")
        )
        if sys.platform == "darwin":
            # Work-around bitmap cutoff on left and right side
            w = self.view_3d_format_btn.Size[0] + 4
        else:
            w = -1
        self.view_3d_format_btn.MinSize = (w, self.vrml_save_as_btn.Size[1])
        self.view_3d_format_btn.Bind(wx.EVT_BUTTON, self.view_3d_format_popup)
        self.view_3d_format_btn.Bind(wx.EVT_CONTEXT_MENU, self.view_3d_format_popup)
        self.view_3d_format_btn.SetToolTipString(lang.getstr("tc.3d"))
        self.view_3d_format_btn.Disable()
        hsizer.Add(
            self.view_3d_format_btn,
            flag=(wx.ALL & ~wx.LEFT) | wx.ALIGN_CENTER_VERTICAL,
            border=border * 2,
        )
        self.tc_vrml_cie = wx.CheckBox(
            panel, -1, "", name="tc_vrml_cie", style=wx.RB_GROUP
        )
        self.tc_vrml_cie.SetToolTipString(lang.getstr("tc.3d"))
        self.Bind(wx.EVT_CHECKBOX, self.tc_vrml_handler, id=self.tc_vrml_cie.GetId())
        hsizer.Add(
            self.tc_vrml_cie,
            flag=(wx.ALL & ~wx.LEFT) | wx.ALIGN_CENTER_VERTICAL,
            border=border * 2,
        )
        self.tc_vrml_cie_colorspace_ctrl = wx.Choice(
            panel, -1, choices=config.VALID_VALUES["tc_vrml_cie_colorspace"]
        )
        self.tc_vrml_cie_colorspace_ctrl.SetToolTipString(lang.getstr("tc.3d"))
        self.Bind(
            wx.EVT_CHOICE,
            self.tc_vrml_handler,
            id=self.tc_vrml_cie_colorspace_ctrl.GetId(),
        )
        hsizer.Add(
            self.tc_vrml_cie_colorspace_ctrl,
            flag=(wx.ALL & ~wx.LEFT) | wx.ALIGN_CENTER_VERTICAL,
            border=border * 2,
        )
        self.tc_vrml_device = wx.CheckBox(panel, -1, "", name="tc_vrml_device")
        self.tc_vrml_device.SetToolTipString(lang.getstr("tc.3d"))
        self.Bind(wx.EVT_CHECKBOX, self.tc_vrml_handler, id=self.tc_vrml_device.GetId())
        hsizer.Add(
            self.tc_vrml_device,
            flag=(wx.ALL & ~wx.LEFT) | wx.ALIGN_CENTER_VERTICAL,
            border=border * 2,
        )
        self.tc_vrml_device_colorspace_ctrl = wx.Choice(
            panel, -1, choices=config.VALID_VALUES["tc_vrml_device_colorspace"]
        )
        self.tc_vrml_device_colorspace_ctrl.SetToolTipString(lang.getstr("tc.3d"))
        self.Bind(
            wx.EVT_CHOICE,
            self.tc_vrml_handler,
            id=self.tc_vrml_device_colorspace_ctrl.GetId(),
        )
        hsizer.Add(
            self.tc_vrml_device_colorspace_ctrl,
            flag=(wx.ALL & ~wx.LEFT) | wx.ALIGN_CENTER_VERTICAL,
            border=border * 2,
        )

        hsizer.Add(
            wx.StaticText(panel, -1, lang.getstr("tc.vrml.black_offset")),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.tc_vrml_black_offset_intctrl = wx.SpinCtrl(
            panel,
            -1,
            size=(90 * scale, -1),
            min=0,
            max=40,
            name="tc_vrml_black_offset_intctrl",
        )
        self.Bind(
            wx.EVT_TEXT,
            self.tc_vrml_black_offset_ctrl_handler,
            id=self.tc_vrml_black_offset_intctrl.GetId(),
        )
        hsizer.Add(
            self.tc_vrml_black_offset_intctrl,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.tc_vrml_use_D50_cb = wx.CheckBox(
            panel, -1, lang.getstr("tc.vrml.use_D50"), name="tc_vrml_use_D50_cb"
        )
        self.Bind(
            wx.EVT_CHECKBOX,
            self.tc_vrml_use_D50_handler,
            id=self.tc_vrml_use_D50_cb.GetId(),
        )
        hsizer.Add(
            self.tc_vrml_use_D50_cb,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.tc_vrml_compress_cb = wx.CheckBox(
            panel, -1, lang.getstr("compression.gzip"), name="tc_vrml_compress_cb"
        )
        self.Bind(
            wx.EVT_CHECKBOX,
            self.tc_vrml_compress_handler,
            id=self.tc_vrml_compress_cb.GetId(),
        )
        hsizer.Add(
            self.tc_vrml_compress_cb,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )

        # buttons
        hsizer = wx.BoxSizer(wx.HORIZONTAL)
        self.sizer.Add(hsizer, flag=(wx.ALL & ~wx.BOTTOM) | wx.ALIGN_CENTER, border=12)

        self.preview_btn = wx.Button(
            panel, -1, lang.getstr("testchart.create"), name="tc_create"
        )
        self.Bind(wx.EVT_BUTTON, self.tc_preview_handler, id=self.preview_btn.GetId())
        hsizer.Add(
            self.preview_btn, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )

        self.save_btn = wx.Button(panel, -1, lang.getstr("save"))
        self.save_btn.Disable()
        self.Bind(wx.EVT_BUTTON, self.tc_save_handler, id=self.save_btn.GetId())
        hsizer.Add(self.save_btn, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border)

        self.save_as_btn = wx.Button(panel, -1, lang.getstr("save_as"))
        self.save_as_btn.Disable()
        self.Bind(wx.EVT_BUTTON, self.tc_save_as_handler, id=self.save_as_btn.GetId())
        hsizer.Add(
            self.save_as_btn, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )

        self.export_btn = wx.Button(panel, -1, lang.getstr("export"), name="tc_export")
        self.export_btn.Disable()
        self.Bind(wx.EVT_BUTTON, self.tc_export_handler, id=self.export_btn.GetId())
        hsizer.Add(
            self.export_btn, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )

        self.clear_btn = wx.Button(
            panel, -1, lang.getstr("testchart.discard"), name="tc_clear"
        )
        self.clear_btn.Disable()
        self.Bind(wx.EVT_BUTTON, self.tc_clear_handler, id=self.clear_btn.GetId())
        hsizer.Add(
            self.clear_btn, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )

        # buttons row 2
        hsizer = wx.BoxSizer(wx.HORIZONTAL)
        self.sizer.Add(hsizer, flag=wx.ALIGN_CENTER | wx.LEFT | wx.RIGHT, border=12)

        hsizer.Add(
            wx.StaticText(panel, -1, lang.getstr("testchart.add_saturation_sweeps")),
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        self.saturation_sweeps_intctrl = wx.SpinCtrl(
            panel,
            -1,
            size=(95 * scale, -1),
            initial=getcfg("tc.saturation_sweeps"),
            min=2,
            max=255,
        )
        self.saturation_sweeps_intctrl.Disable()
        hsizer.Add(
            self.saturation_sweeps_intctrl,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )

        for color in ("R", "G", "B", "C", "M", "Y"):
            name = f"saturation_sweeps_{color}_btn"
            setattr(self, name, wx.Button(panel, -1, color, size=(45 * scale, -1)))
            getattr(self, f"saturation_sweeps_{color}_btn").Disable()
            self.Bind(
                wx.EVT_BUTTON,
                self.tc_add_saturation_sweeps_handler,
                id=getattr(self, name).GetId(),
            )
            hsizer.Add(
                getattr(self, name),
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )

        self.saturation_sweeps_custom_btn = wx.Button(
            panel, -1, "=", size=(45 * scale, -1)
        )
        self.saturation_sweeps_custom_btn.Disable()
        self.Bind(
            wx.EVT_BUTTON,
            self.tc_add_saturation_sweeps_handler,
            id=self.saturation_sweeps_custom_btn.GetId(),
        )
        hsizer.Add(
            self.saturation_sweeps_custom_btn,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        for component in ("R", "G", "B"):
            hsizer.Add(
                wx.StaticText(panel, -1, component),
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )
            name = f"saturation_sweeps_custom_{component}_ctrl"
            setattr(
                self,
                name,
                floatspin.FloatSpin(
                    panel,
                    -1,
                    size=(100 * scale, -1),
                    value=getcfg(f"tc.saturation_sweeps.custom.{component}"),
                    min_val=0,
                    max_val=100,
                    increment=100.0 / 255,
                    digits=2,
                ),
            )
            getattr(self, f"saturation_sweeps_custom_{component}_ctrl").Disable()
            self.Bind(
                floatspin.EVT_FLOATSPIN,
                self.tc_algo_handler,
                id=getattr(self, name).GetId(),
            )
            hsizer.Add(
                getattr(self, name),
                flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
                border=border,
            )

        # buttons row 3
        hsizer = wx.BoxSizer(wx.HORIZONTAL)
        self.sizer.Add(hsizer, flag=wx.ALIGN_CENTER | wx.LEFT | wx.RIGHT, border=12)

        self.add_ti3_btn = wx.Button(
            panel, -1, lang.getstr("testchart.add_ti3_patches")
        )
        self.add_ti3_btn.Disable()
        self.Bind(wx.EVT_BUTTON, self.tc_add_ti3_handler, id=self.add_ti3_btn.GetId())
        hsizer.Add(
            self.add_ti3_btn, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=border
        )
        self.add_ti3_relative_cb = wx.CheckBox(
            panel, -1, lang.getstr("whitepoint.simulate.relative")
        )
        self.add_ti3_relative_cb.Disable()
        self.Bind(
            wx.EVT_CHECKBOX,
            self.tc_add_ti3_relative_handler,
            id=self.add_ti3_relative_cb.GetId(),
        )
        hsizer.Add(
            self.add_ti3_relative_cb,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )
        hsizer.Add((50, 1))

        lstrs = (
            "testchart.sort_RGB_gray_to_top",
            "testchart.sort_RGB_white_to_top",
            "testchart.sort_RGB_red_to_top",
            "testchart.sort_RGB_green_to_top",
            "testchart.sort_RGB_blue_to_top",
            "testchart.sort_RGB_cyan_to_top",
            "testchart.sort_RGB_magenta_to_top",
            "testchart.sort_RGB_yellow_to_top",
            "testchart.sort_by_HSI",
            "testchart.sort_by_HSL",
            "testchart.sort_by_HSV",
            "testchart.sort_by_L",
            "testchart.sort_by_rec709_luma",
            "testchart.sort_by_RGB",
            "testchart.sort_by_RGB_sum",
            "testchart.sort_by_BGR",
            "testchart.optimize_display_response_delay",
            "testchart.interleave",
            "testchart.shift_interleave",
            "testchart.maximize_lightness_difference",
            "testchart.maximize_rec709_luma_difference",
            "testchart.maximize_RGB_difference",
            "testchart.vary_RGB_difference",
        )
        patch_order_choices = [lang.getstr(lstr) for lstr in lstrs]
        self.change_patch_order_ctrl = wx.Choice(panel, -1, choices=patch_order_choices)
        self.change_patch_order_ctrl.SetSelection(0)
        self.change_patch_order_ctrl.SetToolTipString(
            lang.getstr("testchart.change_patch_order")
        )
        hsizer.Add(
            self.change_patch_order_ctrl,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )

        self.change_patch_order_btn = wx.Button(panel, -1, lang.getstr("apply"))
        self.Bind(
            wx.EVT_BUTTON, self.tc_sort_handler, id=self.change_patch_order_btn.GetId()
        )
        hsizer.Add(
            self.change_patch_order_btn,
            flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL,
            border=border,
        )

        # grid
        self.sizer.Add((-1, 12))
        use_native_grid = sys.version_info >= (3, 10)
        grid_cls = wx.grid.Grid if use_native_grid else CustomGrid
        self.grid = grid_cls(panel, -1, size=(-1, 100))
        self.grid.DisableDragColSize()
        self.grid.EnableGridLines(False)
        self.grid.SetCellHighlightPenWidth(0)
        self.grid.SetCellHighlightROPenWidth(0)
        self.grid.SetColLabelSize(self.grid.GetDefaultRowSize())
        self.grid.SetDefaultCellAlignment(wx.ALIGN_CENTER, wx.ALIGN_CENTER)
        self.grid.SetRowLabelAlignment(wx.ALIGN_RIGHT, wx.ALIGN_CENTER)
        self.grid.SetScrollRate(0, 5)
        if hasattr(self.grid, "draw_horizontal_grid_lines"):
            self.grid.draw_horizontal_grid_lines = False
        if hasattr(self.grid, "draw_vertical_grid_lines"):
            self.grid.draw_vertical_grid_lines = False
        self.sizer.Add(self.grid, 1, flag=wx.EXPAND)
        self.grid.CreateGrid(0, 0)
        font = self.grid.GetDefaultCellFont()
        if font.PointSize > 11:
            font.PointSize = 11
            self.grid.SetDefaultCellFont(font)
        self.Bind(wx.grid.EVT_GRID_CELL_CHANGE, self.tc_grid_cell_change_handler)
        self.grid.Bind(
            wx.grid.EVT_GRID_LABEL_LEFT_CLICK, self.tc_grid_label_left_click_handler
        )
        self.grid.Bind(
            wx.grid.EVT_GRID_LABEL_LEFT_DCLICK, self.tc_grid_label_left_dclick_handler
        )
        self.grid.Bind(wx.grid.EVT_GRID_RANGE_SELECT, self.tc_grid_range_select_handler)
        self.grid.DisableDragRowSize()
        if TC_USE_ALTERNATE_PREVIEW:
            separator_color = wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DSHADOW)
            separator = wx.Panel(panel, size=(-1, 1))
            separator.BackgroundColour = separator_color
            self.sizer.Add(separator, flag=wx.EXPAND)

        # preview area
        if TC_USE_ALTERNATE_PREVIEW:
            self.sizer.SetSizeHints(self)
            self.sizer.Layout()
            self.sizer.SetMinSize((self.sizer.MinSize[0], self.sizer.MinSize[1] + 1))
            p1.SetMinSize(self.sizer.MinSize)
            splitter.SplitHorizontally(p1, p2, self.sizer.GetMinSize()[1])
            hsizer = wx.BoxSizer(wx.VERTICAL)
            gradientpanel = get_gradient_panel(p2, lang.getstr("preview"))
            gradientpanel.MinSize = (-1, 23 * scale)
            p2.sizer.Add(gradientpanel, flag=wx.EXPAND)
            p2.sizer.Add(hsizer, 1, flag=wx.EXPAND)
            p2.BackgroundColour = "#333333"
            preview = grid_cls(p2, -1, size=(-1, 100))
            preview.DisableDragColSize()
            preview.DisableDragRowSize()
            preview.EnableEditing(False)
            preview.EnableGridLines(False)
            preview.SetCellHighlightPenWidth(0)
            preview.SetCellHighlightROPenWidth(0)
            preview.SetColLabelSize(self.grid.GetDefaultRowSize())
            preview.SetDefaultCellAlignment(wx.ALIGN_CENTER, wx.ALIGN_CENTER)
            preview.SetRowLabelAlignment(wx.ALIGN_RIGHT, wx.ALIGN_CENTER)
            preview.SetLabelTextColour("#CCCCCC")
            preview.SetScrollRate(0, 5)
            if hasattr(preview, "_default_col_label_renderer"):
                preview._default_col_label_renderer.bgcolor = "#333333"
            if hasattr(preview, "_default_row_label_renderer"):
                preview._default_row_label_renderer.bgcolor = "#333333"
            preview.alternate_cell_background_color = False
            preview.alternate_row_label_background_color = False
            if hasattr(preview, "draw_horizontal_grid_lines"):
                preview.draw_horizontal_grid_lines = False
            if hasattr(preview, "draw_vertical_grid_lines"):
                preview.draw_vertical_grid_lines = False
            if hasattr(preview, "rendernative"):
                preview.rendernative = False
            if hasattr(preview, "style"):
                preview.style = ""
            preview.CreateGrid(0, 0)
            font = preview.GetDefaultCellFont()
            if font.PointSize > 11:
                font.PointSize = 11
                preview.SetDefaultCellFont(font)
            preview.SetLabelFont(font)
            preview.Bind(wx.grid.EVT_GRID_CELL_LEFT_CLICK, self.tc_mouseclick_handler)
            self.preview = preview
            preview.SetDefaultCellBackgroundColour("#333333")
            preview.SetLabelBackgroundColour("#333333")
            hsizer.Add(preview, 1, wx.EXPAND)

            panel = p2

        if sys.platform not in ("darwin", "win32"):
            separator_color = wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DSHADOW)
            separator = wx.Panel(panel, size=(-1, 1))
            separator.BackgroundColour = separator_color
            panel.Sizer.Add(separator, flag=wx.EXPAND)

        # status
        status = wx.StatusBar(self, -1)
        status.SetStatusStyles([wx.SB_FLAT])
        self.SetStatusBar(status)

        # layout
        if TC_USE_ALTERNATE_PREVIEW:
            self.SetMinSize(
                (
                    self.GetMinSize()[0],
                    self.GetMinSize()[1] + splitter.SashSize + p2.sizer.MinSize[1],
                )
            )
        else:
            self.sizer.SetSizeHints(self)
            self.sizer.Layout()

        DEFAULTS.update(
            {
                "position.tcgen.x": self.GetDisplay().ClientArea[0] + 40,
                "position.tcgen.y": self.GetDisplay().ClientArea[1] + 60,
                "size.tcgen.w": self.ClientSize[0],
                "size.tcgen.h": self.ClientSize[1],
            }
        )

        if (
            hascfg("position.tcgen.x")
            and hascfg("position.tcgen.y")
            and hascfg("size.tcgen.w")
            and hascfg("size.tcgen.h")
        ):
            self.SetSaneGeometry(
                int(getcfg("position.tcgen.x")),
                int(getcfg("position.tcgen.y")),
                int(getcfg("size.tcgen.w")),
                int(getcfg("size.tcgen.h")),
            )
        else:
            self.Center()

        self.tc_size_handler()

        children = list(self.GetAllChildren())

        for child in children:
            if hasattr(child, "SetFont"):
                child.SetMaxFontSize(11)
            child.Bind(wx.EVT_KEY_DOWN, self.tc_key_handler)
            if (
                sys.platform == "win32"
                and sys.getwindowsversion() >= (6,)
                and isinstance(child, wx.Panel)
            ):
                # No need to enable double buffering under Linux and Mac OS X.
                # Under Windows, enabling double buffering on the panel seems
                # to work best to reduce flicker.
                child.SetDoubleBuffered(True)
        self.Bind(wx.EVT_MOVE, self.tc_move_handler)
        self.Bind(wx.EVT_SIZE, self.tc_size_handler, self)
        self.Bind(wx.EVT_MAXIMIZE, self.tc_size_handler, self)

        self.Children[0].Bind(wx.EVT_WINDOW_DESTROY, self.tc_destroy_handler)

        self.tc_update_controls()
        self.tc_check()
        if path is not False:
            wx.CallAfter(self.tc_load_cfg_from_ti1, None, path)

    def csv_drop_handler(self, path):
        """Handle dropping a CSV file onto the test chart editor.

        Args:
            path (str): The file path of the dropped CSV file.
        """
        if self.worker.is_working():
            return
        if not self.tc_check_save_ti1():
            return
        self.worker.start(
            self.csv_convert_finish,
            self.csv_convert,
            wargs=(path,),
            progress_msg=lang.getstr("testchart.read"),
            parent=self,
            progress_start=500,
            cancelable=False,
            continue_next=True,
            show_remaining_time=False,
            fancy=False,
        )

    def csv_convert(self, path):
        """Convert a CSV file to a temporary TI1 file.

        Args:
            path (str): The file path of the CSV file to convert.

        Returns:
            CGATS or Exception: The converted CGATS object or an exception if
                an error occurred.
        """
        # Read CSV file and get rows
        rows = []
        maxval = 100.0
        try:
            with open(path, "rb") as csvfile:
                sniffer = csv.Sniffer()
                rawcsv = csvfile.read()
                dialect = sniffer.sniff(rawcsv, delimiters=",;\t")
                has_header = sniffer.has_header(rawcsv)
                csvfile.seek(0)
                for i, row in enumerate(csv.reader(csvfile, dialect=dialect)):
                    if has_header:
                        continue
                    if len(row) == 3 or len(row) == 6:
                        # Add row number before first column
                        row.insert(0, i)
                    if len(row) not in (4, 7):
                        raise ValueError(lang.getstr("error.testchart.invalid", path))
                    row = [int(row[0])] + [float(v) for v in row[1:]]
                    for v in row[1:]:
                        maxval = max(maxval, v)
                    rows.append(row)
        except Exception as exception:
            result = exception
        else:
            # Scale to 0..100 if actual value range is different
            if maxval > 100:
                for i, row in enumerate(rows):
                    rows[i][1:] = [v / maxval * 100 for v in row[1:]]
            # Create temporary TI1
            ti1 = CGATS(
                """CTI1
KEYWORD "COLOR_REP"
COLOR_REP "RGB"
NUMBER_OF_FIELDS 7
BEGIN_DATA_FORMAT
SAMPLE_ID RGB_R RGB_G RGB_B XYZ_X XYZ_Y XYZ_Z
END_DATA_FORMAT
NUMBER_OF_SETS 4
BEGIN_DATA
END_DATA"""
            )
            # Add rows to TI1
            data = ti1[0].DATA
            for row in rows:
                if len(row) < 7:
                    # Missing XYZ, add via simple sRGB-like model
                    row.extend(
                        v * 100
                        for v in argyll_rgb2xyz.rgb2xyz(*[v / 100.0 for v in row[1:]])
                    )
                data.add_data(row)
            # Create temp dir
            result = tmp = self.worker.create_tempdir()
        if not isinstance(result, Exception):
            # Write out temporary TI1
            ti1.filename = os.path.join(
                tmp, os.path.splitext(os.path.basename(path))[0] + ".ti1"
            )
            ti1.write()
            result = ti1
        return result

    def csv_convert_finish(self, result):
        """Handle the completion of the CSV conversion.

        Args:
            result (CGATS or Exception): The result of the conversion.
        """
        if isinstance(result, Exception):
            show_result_dialog(result, self)
        else:
            self.tc_load_cfg_from_ti1(None, result.filename, resume=True)

    def precond_profile_drop_handler(self, path):
        """Handle dropping a profile file onto the precondition profile control.

        Args:
            path (str): The file path of the dropped profile.
        """
        self.tc_precond_profile.SetPath(path)
        self.tc_precond_profile_handler()

    def get_commands(self):
        """Get a list of commands for the test chart editor.

        Returns:
            list: List of command strings.
        """
        return [
            *self.get_common_commands(),
            "testchart-editor [filename | create filename]",
            "load <filename>",
        ]

    def process_data(self, data):
        """Process command line data for the test chart editor.

        Args:
            data (list): List of command line arguments.

        Returns:
            str: "ok" if the command was processed successfully, "fail" if it
                failed, or "invalid" if the command was not recognized.
        """
        if (
            data[0] == "testchart-editor"
            and (len(data) < 3 or (len(data) == 3 and data[1] == "create"))
        ) or (data[0] == "load" and len(data) == 2):
            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 len(data) == 3:
                # Create testchart
                wx.CallAfter(self.tc_preview_handler, path=data[2])
            return "ok"
        return "invalid"

    def ti1_drop_handler(self, path):
        """Handle dropping a TI1 file onto the test chart editor."""
        self.tc_load_cfg_from_ti1(None, path)

    def resize_grid(self):
        """Resize the grid columns based on the current size of the grid."""
        num_cols = self.grid.GetNumberCols()
        if not num_cols or num_cols == 1:
            return
        grid_w = (
            self.grid.GetSize()[0]
            - self.grid.GetRowLabelSize()
            - self.grid.GetDefaultRowSize()
        )
        col_w = round(grid_w / (num_cols - 1))
        last_col_w = grid_w - col_w * (num_cols - 2)
        for i in range(num_cols):
            if i == 3:
                w = self.grid.GetDefaultRowSize()
            elif i == num_cols - 2:
                w = last_col_w - wx.SystemSettings_GetMetric(wx.SYS_VSCROLL_X)
            else:
                w = col_w
            self.grid.SetColSize(i, w)
        self.grid.SetMargins(0 - wx.SystemSettings_GetMetric(wx.SYS_VSCROLL_X), 0)
        self.grid.ForceRefresh()
        if hasattr(self, "preview"):
            num_cols = self.preview.GetNumberCols()
            if not num_cols:
                return
            grid_w = (
                self.preview.GetSize()[0]
                - self.preview.GetRowLabelSize()
                - wx.SystemSettings_GetMetric(wx.SYS_VSCROLL_X)
            )
            col_w = round(grid_w / num_cols)
            for i in range(num_cols):
                self.preview.SetColSize(i, col_w)
            self.preview.SetMargins(
                0 - wx.SystemSettings_GetMetric(wx.SYS_VSCROLL_X), 0
            )
            self.preview.ForceRefresh()

    def tc_grid_range_select_handler(self, event):
        """Handler for grid range selection events.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if DEBUG:
            print("[D] tc_grid_range_select_handler")
        if not self.grid.GetBatchCount():
            wx.CallAfter(self.tc_set_default_status)
        event.Skip()

    def tc_grid_label_left_click_handler(self, event):
        """Handler for left-clicking on a grid label.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        wx.CallAfter(self.tc_set_default_status)
        event.Skip()

    def tc_grid_label_left_dclick_handler(self, event):
        """Handler for double-clicking on a grid label.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        row, col = event.GetRow(), event.GetCol()
        if col == -1:  # row label clicked
            _data = self.ti1.queryv1("DATA")
            wp = self.ti1.queryv1("APPROX_WHITE_POINT")
            if wp:
                wp = [float(v) for v in wp.split()]
                wp = [(v / wp[1]) * 100.0 for v in wp]
            else:
                wp = colormath.get_standard_illuminant("D65", scale=100)
            newdata = {
                "SAMPLE_ID": row + 2,
                "RGB_R": 100.0,
                "RGB_G": 100.0,
                "RGB_B": 100.0,
                "XYZ_X": wp[0],
                "XYZ_Y": 100.0,
                "XYZ_Z": wp[2],
            }
            self.tc_add_data(row, [newdata])
        event.Skip()

    def tc_key_handler(self, event):
        """Handler for key events in the test chart editor.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        # AltDown
        # CmdDown
        # ControlDown
        # GetKeyCode
        # GetModifiers
        # GetPosition
        # GetRawKeyCode
        # GetRawKeyFlags
        # GetUniChar
        # GetUnicodeKey
        # GetX
        # GetY
        # HasModifiers
        # KeyCode
        # MetaDown
        # Modifiers
        # Position
        # RawKeyCode
        # RawKeyFlags
        # ShiftDown
        # UnicodeKey
        # X
        # Y
        if DEBUG:
            print(
                "[D] event.KeyCode",
                event.GetKeyCode(),
                "event.RawKeyCode",
                event.GetRawKeyCode(),
                "event.UniChar",
                event.GetUniChar(),
                "event.UnicodeKey",
                event.GetUnicodeKey(),
                "CTRL/CMD:",
                event.ControlDown() or event.CmdDown(),
                "ALT:",
                event.AltDown(),
                "SHIFT:",
                event.ShiftDown(),
            )
        if (
            event.ControlDown() or event.CmdDown()
        ):  # CTRL (Linux/Mac/Windows) / CMD (Mac)
            key = event.GetKeyCode()
            focus = self.FindFocus()
            if focus and self.grid in (
                focus,
                focus.GetParent(),
                focus.GetGrandParent(),
            ):
                if key in (8, 127):  # BACKSPACE / DEL
                    rows = self.grid.GetSelectionRows()
                    if (
                        rows
                        and len(rows)
                        and min(rows) >= 0
                        and max(rows) + 1 <= self.grid.GetNumberRows()
                    ):
                        if len(rows) == self.grid.GetNumberRows():
                            self.tc_check_save_ti1()
                        else:
                            self.tc_delete_rows(rows)
                        return
                elif key == 86 and self.grid.IsEditable():
                    # V
                    wx.CallAfter(self.tc_save_check)
            if key == 83:  # S
                if hasattr(self, "ti1"):
                    if (
                        event.ShiftDown()
                        or event.AltDown()
                        or not self.ti1.filename
                        or not os.path.exists(self.ti1.filename)
                    ):
                        self.tc_save_as_handler()
                    elif self.ti1.modified:
                        self.tc_save_handler(True)
                return
            event.Skip()
        else:
            event.Skip()

    def tc_sash_handler(self, event):
        """Handler for the sash position change event.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        if event.GetSashPosition() < self.sizer.GetMinSize()[1]:
            self.splitter.SetSashPosition(self.sizer.GetMinSize()[1])
        event.Skip()

    def tc_size_handler(self, event=None):
        """Handler for the window size event.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        wx.CallAfter(self.resize_grid)
        if self.IsShownOnScreen() and not self.IsMaximized() and not self.IsIconized():
            w, h = self.ClientSize
            setcfg("size.tcgen.w", w)
            setcfg("size.tcgen.h", h)
        if event:
            event.Skip()

    def tc_sort_handler(self, event):
        """Handler for the change patch order button.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        idx = self.change_patch_order_ctrl.GetSelection()
        if idx == 0:
            self.ti1.sort_rgb_gray_to_top()
        elif idx == 1:
            self.ti1.sort_rgb_white_to_top()
        elif idx == 2:
            self.ti1.sort_rgb_to_top(1, 0, 0)  # Red
        elif idx == 3:
            self.ti1.sort_rgb_to_top(0, 1, 0)  # Green
        elif idx == 4:
            self.ti1.sort_rgb_to_top(0, 0, 1)  # Blue
        elif idx == 5:
            self.ti1.sort_rgb_to_top(0, 1, 1)  # Cyan
        elif idx == 6:
            self.ti1.sort_rgb_to_top(1, 0, 1)  # Magenta
        elif idx == 7:
            self.ti1.sort_rgb_to_top(1, 1, 0)  # Yellow
        elif idx == 8:
            self.ti1.sort_by_hsi()
        elif idx == 9:
            self.ti1.sort_by_hsl()
        elif idx == 10:
            self.ti1.sort_by_hsv()
        elif idx == 11:
            self.ti1.sort_by_l()
        elif idx == 12:
            self.ti1.sort_by_rec709_luma()
        elif idx == 13:
            self.ti1.sort_by_rgb()
        elif idx == 14:
            self.ti1.sort_by_rgb_sum()
        elif idx == 15:
            self.ti1.sort_by_bgr()
        elif idx == 16:
            # Minimize display response delay
            self.ti1.sort_by_bgr()
            self.ti1.sort_rgb_gray_to_top()
            self.ti1.sort_rgb_white_to_top()
        elif idx == 17:
            # Interleave
            self.ti1.checkerboard(None, None)
        elif idx == 18:
            # Shift & interleave
            self.ti1.checkerboard(None, None, split_grays=True, shift=True)
        elif idx == 19:
            # Maximize L* difference
            self.ti1.checkerboard(sort1=stable_sort_by_l)
        elif idx == 20:
            # Maximize Rec. 709 luma difference
            self.ti1.checkerboard(sort_by_rec709_luma)
        elif idx == 21:
            # Maximize RGB difference
            self.ti1.checkerboard(sort_by_rgb_sum)
        elif idx == 22:
            # Vary RGB difference
            self.ti1.checkerboard(sort_by_rgb, None, split_grays=True, shift=True)
        self.tc_clear(False)
        self.tc_preview(True)

    def tc_enable_sort_controls(self):
        """Enable or disable the patch order controls based on the current state."""
        enable = hasattr(self, "ti1")
        self.change_patch_order_ctrl.Enable(enable)
        self.change_patch_order_btn.Enable(enable)

    def tc_grid_cell_change_handler(self, event, save_check=True):
        """Handler for grid cell changes.

        Args:
            event (wx.grid.GridEvent): The grid event that triggered this handler.
            save_check (bool): Whether to check for unsaved changes.
        """
        data = self.ti1[0]["DATA"]
        sample = data[event.GetRow()]
        label = self.label_b2a.get(self.grid.GetColLabelValue(event.GetCol()))
        strval = "0" + self.grid.GetCellValue(event.GetRow(), event.GetCol()).replace(
            ",", "."
        )
        value_set = False
        try:
            value = float(strval)
            if value > 100:
                raise ValueError(f"RGB value {value!r}% is invalid")
            if value < 0:
                raise ValueError(f"Negative RGB value {value!r}% is invalid")
        except ValueError:
            if not self.grid.GetBatchCount():
                wx.Bell()
            if label in list(self.ti1[0]["DATA_FORMAT"].values()):
                strval = str(sample[label])
                if "." in strval:
                    strval = strval.rstrip("0").rstrip(".")
            else:
                strval = ""
        else:
            if label in ("RGB_R", "RGB_G", "RGB_B"):
                sample[label] = value
                # If the same RGB combo is already in the ti1, use its XYZ
                # TODO: Implement proper lookup when using precond profile
                # This costs too much performance when updating multiple cells!
                # (e.g. paste operation from spreadsheet)
                # ref = data.queryi({"RGB_R": sample["RGB_R"],
                # "RGB_G": sample["RGB_G"],
                # "RGB_B": sample["RGB_B"]})
                # if ref:
                # for i in ref:
                # if ref[i] != sample:
                # ref = ref[i]
                # break
                # if "XYZ_X" in ref:
                # XYZ = [
                #     component / 100.0
                #     for component in (ref["XYZ_X"], ref["XYZ_Y"], ref["XYZ_Z"])
                # ]
                # else:
                # Fall back to default D65-ish values
                XYZ = argyll_rgb2xyz.rgb2xyz(
                    *[
                        component / 100.0
                        for component in (
                            sample["RGB_R"],
                            sample["RGB_G"],
                            sample["RGB_B"],
                        )
                    ]
                )
                sample["XYZ_X"], sample["XYZ_Y"], sample["XYZ_Z"] = [
                    component * 100.0 for component in XYZ
                ]
                # FIXME: Should this be removed? There are no XYZ fields in the editor
                # for label in ("XYZ_X", "XYZ_Y", "XYZ_Z"):
                # for col in range(self.grid.GetNumberCols()):
                # if self.label_b2a.get(self.grid.GetColLabelValue(col)) == label:
                # self.grid.SetCellValue(
                #     event.GetRow(), col, str(round(sample[label], 4))
                # )
                # value_set = True
            elif label in ("XYZ_X", "XYZ_Y", "XYZ_Z"):
                # FIXME: Should this be removed? There are no XYZ fields in the editor
                if value < 0:
                    value = 0.0
                sample[label] = value
                RGB = argyll_rgb2xyz.xyz2rgb(
                    *[
                        component / 100.0
                        for component in (
                            sample["XYZ_X"],
                            sample["XYZ_Y"],
                            sample["XYZ_Z"],
                        )
                    ]
                )
                sample["RGB_R"], sample["RGB_G"], sample["RGB_B"] = [
                    component * 100.0 for component in RGB
                ]
                for label in ("RGB_R", "RGB_G", "RGB_B"):
                    for col in range(self.grid.GetNumberCols()):
                        if self.label_b2a.get(self.grid.GetColLabelValue(col)) == label:
                            self.grid.SetCellValue(
                                event.GetRow(), col, str(round(sample[label], 4))
                            )
                            value_set = True
            self.tc_grid_setcolorlabel(event.GetRow(), data)
            if not self.grid.GetBatchCount() and save_check:
                self.tc_save_check()
        if not value_set:
            self.grid.SetCellValue(
                event.GetRow(), event.GetCol(), re.sub(r"^0+(?!\.)", "", strval) or "0"
            )

    def tc_white_patches_handler(self, event=None):
        """Handler for the white patches control.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        setcfg("tc_white_patches", self.tc_white_patches.GetValue())
        self.tc_check()
        if event:
            event.Skip()

    def tc_black_patches_handler(self, event=None):
        """Handler for the black patches control.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        setcfg("tc_black_patches", self.tc_black_patches.GetValue())
        self.tc_check()
        if event:
            event.Skip()

    def tc_single_channel_patches_handler(self, event=None):
        """Handler for the single channel patches control.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        if event:
            event.Skip()
            event = CustomEvent(event.GetEventType(), event.GetEventObject())
        if event and event.GetEventType() == wx.EVT_TEXT.evtType[0]:
            # 3 seconds delay to allow user
            # to finish keying in a value before it is validated
            wx.CallLater(3000, self.tc_single_channel_patches_handler2, event)
        else:
            wx.CallAfter(self.tc_single_channel_patches_handler2, event)

    def tc_single_channel_patches_handler2(self, event=None):
        """Handler for the single channel patches control.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        if self.tc_single_channel_patches.GetValue() == 1:
            if (
                event
                and event.GetEventType() in (0, wx.EVT_SPINCTRL.evtType[0])
                and getcfg("tc_single_channel_patches") == 2
            ):  # decrease
                self.tc_single_channel_patches.SetValue(0)
            else:  # increase
                self.tc_single_channel_patches.SetValue(2)
        setcfg("tc_single_channel_patches", self.tc_single_channel_patches.GetValue())
        self.tc_check()

    def tc_gray_handler(self, event=None):
        """Handler for the gray patches control.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        if event:
            event.Skip()
            event = CustomEvent(event.GetEventType(), event.GetEventObject())
        if event and event.GetEventType() == wx.EVT_TEXT.evtType[0]:
            # 3 seconds delay to allow user
            # to finish keying in a value before it is validated
            wx.CallLater(3000, self.tc_gray_handler2, event)
        else:
            wx.CallAfter(self.tc_gray_handler2, event)

    def tc_gray_handler2(self, event=None):
        """Handler for the gray patches control.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        if self.tc_gray_patches.GetValue() == 1:
            if (
                event
                and event.GetEventType() in (0, wx.EVT_SPINCTRL.evtType[0])
                and getcfg("tc_gray_patches") == 2
            ):  # decrease
                self.tc_gray_patches.SetValue(0)
            else:  # increase
                self.tc_gray_patches.SetValue(2)
        setcfg("tc_gray_patches", self.tc_gray_patches.GetValue())
        self.tc_check()

    def tc_fullspread_handler(self, event=None):
        """Handler for the full spread patches control.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        setcfg("tc_fullspread_patches", self.tc_fullspread_patches.GetValue())
        self.tc_algo_handler()
        self.tc_check()

    def tc_gamma_handler(self, event):
        """Handler for the gamma control.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        setcfg("tc_gamma", self.tc_gamma_floatctrl.GetValue())

    def tc_get_total_patches(
        self,
        white_patches=None,
        black_patches=None,
        single_channel_patches=None,
        gray_patches=None,
        multi_steps=None,
        multi_bcc_steps=None,
        fullspread_patches=None,
    ):
        """Get the total number of patches for the test chart.

        Args:
            white_patches (int, optional): Number of white patches.
            black_patches (int, optional): Number of black patches.
            single_channel_patches (int, optional): Number of single channel patches.
            gray_patches (int, optional): Number of gray patches.
            multi_steps (int, optional): Number of multi steps.
            multi_bcc_steps (int, optional): Number of multi BCC steps.
            fullspread_patches (int, optional): Number of full spread patches.

        Returns:
            int: The total number of patches.
        """
        if (
            hasattr(self, "ti1")
            and [
                white_patches,
                black_patches,
                single_channel_patches,
                gray_patches,
                multi_steps,
                multi_bcc_steps,
                fullspread_patches,
            ]
            == [None] * 7
        ):
            return self.ti1.queryv1("NUMBER_OF_SETS")
        if white_patches is None:
            white_patches = self.tc_white_patches.GetValue()
        if black_patches is None:
            if self.worker.argyll_version >= [1, 6]:
                black_patches = self.tc_black_patches.GetValue()
            elif hasattr(self, "ti1"):
                black_patches = self.ti1.queryv1("BLACK_COLOR_PATCHES")
        if single_channel_patches is None:
            single_channel_patches = self.tc_single_channel_patches.GetValue()
        if gray_patches is None:
            gray_patches = self.tc_gray_patches.GetValue()
        if (
            gray_patches == 0
            and (single_channel_patches > 0 or black_patches > 0)
            and white_patches > 0
        ):
            gray_patches = 2
        if multi_steps is None:
            multi_steps = self.tc_multi_steps.GetValue()
        if (
            multi_bcc_steps is None
            and getcfg("tc_multi_bcc")
            and self.worker.argyll_version >= [1, 6]
        ):
            multi_bcc_steps = self.tc_multi_steps.GetValue()
        if fullspread_patches is None:
            fullspread_patches = self.tc_fullspread_patches.GetValue()
        return get_total_patches(
            white_patches,
            black_patches,
            single_channel_patches,
            gray_patches,
            multi_steps,
            multi_bcc_steps,
            fullspread_patches,
        )

    def tc_get_black_patches(self):
        """Get the number of black patches for the test chart.

        Returns:
            int: The number of black patches.
        """
        if self.worker.argyll_version >= [1, 6]:
            black_patches = self.tc_black_patches.GetValue()
        else:
            black_patches = 0
        single_channel_patches = self.tc_single_channel_patches.GetValue()
        gray_patches = self.tc_gray_patches.GetValue()
        if gray_patches == 0 and single_channel_patches > 0 and black_patches > 0:
            gray_patches = 2
        multi_steps = self.tc_multi_steps.GetValue()
        if (
            multi_steps > 1 or gray_patches > 1
        ):  # black always in multi channel or gray patches
            black_patches -= 1
        return max(0, black_patches)

    def tc_get_white_patches(self):
        """Get the number of white patches for the test chart.

        Returns:
            int: The number of white patches.
        """
        white_patches = self.tc_white_patches.GetValue()
        single_channel_patches = self.tc_single_channel_patches.GetValue()
        gray_patches = self.tc_gray_patches.GetValue()
        if gray_patches == 0 and single_channel_patches > 0 and white_patches > 0:
            gray_patches = 2
        multi_steps = self.tc_multi_steps.GetValue()
        if (
            multi_steps > 1 or gray_patches > 1
        ):  # white always in multi channel or gray patches
            white_patches -= 1
        return max(0, white_patches)

    def tc_multi_steps_handler(self, event=None):
        """Handler for the multi steps control.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        if event:
            event.Skip()
            event = CustomEvent(event.GetEventType(), event.GetEventObject())
        if event and event.GetEventType() == wx.EVT_TEXT.evtType[0]:
            # 3 seconds delay to allow user
            # to finish keying in a value before it is validated
            wx.CallLater(3000, self.tc_multi_steps_handler2, event)
        else:
            wx.CallAfter(self.tc_multi_steps_handler2, event)

    def tc_multi_steps_handler2(self, event=None):
        """Handle the multi steps control.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        if self.tc_multi_steps.GetValue() == 1:
            if (
                event
                and event.GetEventType() in (0, wx.EVT_SPINCTRL.evtType[0])
                and getcfg("tc_multi_steps") == 2
            ):  # decrease
                self.tc_multi_steps.SetValue(0)
            else:  # increase
                self.tc_multi_steps.SetValue(2)
        multi_steps = self.tc_multi_steps.GetValue()
        multi_patches = int(math.pow(multi_steps, 3))
        if getcfg("tc_multi_bcc") and self.worker.argyll_version >= [1, 6]:
            pref = "tc_multi_bcc_steps"
            if multi_steps:
                multi_patches += int(math.pow(multi_steps - 1, 3))
                multi_steps += multi_steps - 1
            setcfg("tc_multi_steps", self.tc_multi_steps.GetValue())
        else:
            pref = "tc_multi_steps"
            setcfg("tc_multi_bcc_steps", 0)
        self.tc_multi_patches.SetLabel(
            lang.getstr("tc.multidim.patches", (multi_patches, multi_steps))
        )
        setcfg(pref, self.tc_multi_steps.GetValue())
        self.tc_check()

    def tc_neutral_axis_emphasis_handler(self, event=None):
        """Handler for the neutral axis emphasis slider or intctrl.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        if event.GetId() == self.tc_neutral_axis_emphasis_slider.GetId():
            self.tc_neutral_axis_emphasis_intctrl.SetValue(
                self.tc_neutral_axis_emphasis_slider.GetValue()
            )
        else:
            self.tc_neutral_axis_emphasis_slider.SetValue(
                self.tc_neutral_axis_emphasis_intctrl.GetValue()
            )
        setcfg(
            "tc_neutral_axis_emphasis",
            self.tc_neutral_axis_emphasis_intctrl.GetValue() / 100.0,
        )
        self.tc_algo_handler()

    def tc_dark_emphasis_handler(self, event=None):
        """Handler for the dark emphasis slider or intctrl.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        if event.GetId() == self.tc_dark_emphasis_slider.GetId():
            self.tc_dark_emphasis_intctrl.SetValue(
                self.tc_dark_emphasis_slider.GetValue()
            )
        else:
            self.tc_dark_emphasis_slider.SetValue(
                self.tc_dark_emphasis_intctrl.GetValue()
            )
        setcfg("tc_dark_emphasis", self.tc_dark_emphasis_intctrl.GetValue() / 100.0)
        self.tc_algo_handler()

    def tc_algo_handler(self, event=None):
        """Handler for the test chart algorithm selection.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        tc_algo_enable = self.tc_fullspread_patches.GetValue() > 0
        self.tc_algo.Enable(tc_algo_enable)
        tc_algo = self.tc_algos_ba[self.tc_algo.GetStringSelection()]
        self.tc_adaption_slider.Enable(tc_algo_enable and tc_algo == "")
        self.tc_adaption_intctrl.Enable(tc_algo_enable and tc_algo == "")
        tc_precond_enable = tc_algo in ("I", "Q", "R", "t") or (
            tc_algo == "" and self.tc_adaption_slider.GetValue() > 0
        )
        if self.worker.argyll_version >= [1, 3, 3]:
            self.tc_neutral_axis_emphasis_slider.Enable(
                tc_algo_enable and tc_precond_enable
            )
            self.tc_neutral_axis_emphasis_intctrl.Enable(
                tc_algo_enable and tc_precond_enable
            )
        self.tc_precond.Enable(bool(getcfg("tc_precond_profile")))
        if not getcfg("tc_precond_profile"):
            self.tc_precond.SetValue(False)
        else:
            self.tc_precond.SetValue(bool(int(getcfg("tc_precond"))))
        if self.worker.argyll_version >= [1, 6, 2]:
            tc_dark_emphasis_enable = self.worker.argyll_version >= [1, 6, 3] or (
                tc_precond_enable
                and bool(int(getcfg("tc_precond")))
                and bool(getcfg("tc_precond_profile"))
            )
            self.tc_dark_emphasis_slider.Enable(tc_dark_emphasis_enable)
            self.tc_dark_emphasis_intctrl.Enable(tc_dark_emphasis_enable)
        self.tc_angle_slider.Enable(tc_algo_enable and tc_algo in ("i", "I"))
        self.tc_angle_intctrl.Enable(tc_algo_enable and tc_algo in ("i", "I"))
        setcfg("tc_algo", tc_algo)
        self.tc_enable_add_precond_controls()

    def tc_enable_add_precond_controls(self):
        """Enable or disable controls related to additional preconditioning."""
        _tc_algo = getcfg("tc_algo")
        add_preconditioned_enable = hasattr(self, "ti1") and bool(
            getcfg("tc_precond_profile")
        )
        self.saturation_sweeps_intctrl.Enable(add_preconditioned_enable)
        for color in ("R", "G", "B", "C", "M", "Y"):
            getattr(self, f"saturation_sweeps_{color}_btn").Enable(
                add_preconditioned_enable
            )
        RGB = {}
        for component in ("R", "G", "B"):
            ctrl = getattr(self, f"saturation_sweeps_custom_{component}_ctrl")
            ctrl.Enable(add_preconditioned_enable)
            RGB[component] = ctrl.GetValue()
        self.saturation_sweeps_custom_btn.Enable(
            add_preconditioned_enable and not (RGB["R"] == RGB["G"] == RGB["B"])
        )
        self.add_ti3_btn.Enable(add_preconditioned_enable)
        self.add_ti3_relative_cb.Enable(add_preconditioned_enable)

    def tc_adaption_handler(self, event=None):
        """Handler for adapting the test chart based on the adaption slider or intctrl.

        Args:
            event (wx.Event, optional): The event that triggered this handler.
        """
        if event.GetId() == self.tc_adaption_slider.GetId():
            self.tc_adaption_intctrl.SetValue(self.tc_adaption_slider.GetValue())
        else:
            self.tc_adaption_slider.SetValue(self.tc_adaption_intctrl.GetValue())
        setcfg("tc_adaption", self.tc_adaption_intctrl.GetValue() / 100.0)
        self.tc_algo_handler()

    def tc_add_saturation_sweeps_handler(self, event):
        """Handler for adding saturation sweeps to the test chart.

        Args:
            event (wx.Event): The event that triggered this handler.
        """
        try:
            profile = ICCProfile(getcfg("tc_precond_profile"))
        except (OSError, ICCProfileInvalidError) as exception:
            show_result_dialog(exception, self)
            return

        rgb_space = profile.get_rgb_space()
        if not rgb_space:
            show_result_dialog(
                Error(
                    lang.getstr(
                        "profile.required_tags_missing",
                        lang.getstr("profile.type.shaper_matrix"),
                    )
                ),
                self,
            )
            return
        R, G, B = {
            self.saturation_sweeps_R_btn.GetId(): (1, 0, 0),
            self.saturation_sweeps_G_btn.GetId(): (0, 1, 0),
            self.saturation_sweeps_B_btn.GetId(): (0, 0, 1),
            self.saturation_sweeps_C_btn.GetId(): (0, 1, 1),
            self.saturation_sweeps_M_btn.GetId(): (1, 0, 1),
            self.saturation_sweeps_Y_btn.GetId(): (1, 1, 0),
            self.saturation_sweeps_custom_btn.GetId(): (
                self.saturation_sweeps_custom_R_ctrl.GetValue() / 100.0,
                self.saturation_sweeps_custom_G_ctrl.GetValue() / 100.0,
                self.saturation_sweeps_custom_B_ctrl.GetValue() / 100.0,
            ),
        }[event.GetId()]
        maxv = self.saturation_sweeps_intctrl.GetValue()
        newdata = []
        rows = self.grid.GetSelectionRows()
        row = rows[-1] if rows else self.grid.GetNumberRows() - 1
        for i in range(maxv):
            _saturation = 1.0 / (maxv - 1) * i
            RGB, xyY = colormath.RGBsaturation(R, G, B, 1.0 / (maxv - 1) * i, rgb_space)
            X, Y, Z = colormath.xyY2XYZ(*xyY)
            newdata.append(
                {
                    "SAMPLE_ID": row + 2,
                    "RGB_R": round(RGB[0] * 100, 4),
                    "RGB_G": round(RGB[1] * 100, 4),
                    "RGB_B": round(RGB[2] * 100, 4),
                    "XYZ_X": X * 100,
                    "XYZ_Y": Y * 100,
                    "XYZ_Z": Z * 100,
                }
            )
        self.tc_add_data(row, newdata)
        self.grid.select_row(row + len(newdata))

    def tc_drop_ti3_handler(self, path):
        """Handler for dropping a TI3 file to the test chart.

        Args:
            path (str): Path to the dropped file.
        """
        if not hasattr(self, "ti1"):
            wx.Bell()
        elif getcfg("tc_precond_profile"):
            self.tc_add_ti3_handler(None, path)
        else:
            show_result_dialog(lang.getstr("tc.precond.notset"), self)

    def tc_add_ti3_handler(self, event, chart=None):
        """Handler for adding a TI3 file to the test chart.

        Args:
            event (wx.Event): The event that triggered this handler.
            chart (str, optional): Path to the chart file. Defaults to None.
        """
        try:
            profile = ICCProfile(getcfg("tc_precond_profile"))
        except (OSError, ICCProfileInvalidError) as exception:
            show_result_dialog(exception, self)
            return

        if not chart:
            defaultDir, defaultFile = get_verified_path("testchart.reference")
            dlg = wx.FileDialog(
                self,
                lang.getstr("testchart_or_reference"),
                defaultDir=defaultDir,
                defaultFile=defaultFile,
                wildcard=(
                    f"{lang.getstr('filetype.ti1_ti3_txt')}|*.cgats;*.cie;"
                    "*.gam;*.icc;*.icm;*.jpg;*.jpeg;*.png;*.ti1;*.ti2;*.ti3;"
                    "*.tif;*.tiff;*.txt"
                ),
                style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
            )
            dlg.Center(wx.BOTH)
            result = dlg.ShowModal()
            if result == wx.ID_OK:
                chart = dlg.GetPath()
                setcfg("testchart.reference", chart)
            dlg.Destroy()
            if result != wx.ID_OK:
                return

        use_gamut = False

        # Determine if this is an image
        filename, ext = os.path.splitext(chart)
        if ext.lower() in (".jpg", ".jpeg", ".png", ".tif", ".tiff"):
            llevel = wx.Log.GetLogLevel()
            wx.Log.SetLogLevel(0)  # Suppress TIFF library related message popups
            try:
                img = wx.Image(chart, wx.BITMAP_TYPE_ANY)
                if not img.IsOk():
                    raise Error(lang.getstr("error.file_type_unsupported"))
            except Exception as exception:
                show_result_dialog(exception, self)
                return
            finally:
                wx.Log.SetLogLevel(llevel)
            if TEST:
                dlg = ConfirmDialog(
                    self,
                    title=lang.getstr("testchart.add_ti3_patches"),
                    msg=lang.getstr("gamut"),
                    ok="L*a*b*",
                    alt="RGB",
                    bitmap=get_icon(32, APPNAME + "-testchart-editor"),
                )
                result = dlg.ShowModal()
                if result == wx.ID_CANCEL:
                    return
                use_gamut = result == wx.ID_OK
        else:
            img = None
            if ext.lower() in (".icc", ".icm"):
                try:
                    nclprof = ICCProfile(chart)
                    if (
                        nclprof.profileClass != "nmcl"
                        or "ncl2" not in nclprof.tags
                        or not isinstance(nclprof.tags.ncl2, NamedColor2Type)
                        or nclprof.connectionColorSpace not in ("Lab", "XYZ")
                    ):
                        raise Error(lang.getstr("profile.only_named_color"))
                except Exception as exception:
                    show_result_dialog(exception, self)
                    return
                if nclprof.connectionColorSpace == "Lab":
                    data_format = "LAB_L LAB_A LAB_B"
                else:
                    data_format = " XYZ_X XYZ_Y XYZ_Z"
                chart = [
                    "GAMUT  ",
                    "BEGIN_DATA_FORMAT",
                    data_format,
                    "END_DATA_FORMAT",
                    "BEGIN_DATA",
                    "END_DATA",
                ]
                if "wtpt" in nclprof.tags:
                    chart.insert(1, 'KEYWORD "APPROX_WHITE_POINT"')
                    chart.insert(
                        2,
                        'APPROX_WHITE_POINT "{:.4f} {:.4f} {:.4f}"'.format(
                            *tuple(v * 100 for v in list(nclprof.tags.wtpt.ir.values()))
                        ),
                    )
                for k in nclprof.tags.ncl2:
                    v = nclprof.tags.ncl2[k]
                    chart.insert(
                        -1, "{:.4f} {:.4f} {:.4f}".format(*tuple(v.pcs.values()))
                    )
                chart = "\n".join(chart)

        self.worker.start(
            self.tc_add_ti3_consumer,
            self.tc_add_ti3,
            cargs=(profile,),
            wargs=(chart, img, use_gamut, profile),
            wkwargs={},
            progress_msg=lang.getstr("testchart.add_ti3_patches"),
            parent=self,
            progress_start=500,
            fancy=False,
        )

    def tc_add_ti3_consumer(self, result, profile=None):
        """Consumer for the tc_add_ti3 worker.

        Args:
            result (str or Exception): Result from the worker, either a path
                to the processed ti3 file or an Exception.
            profile (ICCProfile, optional): Preconditioning profile. Defaults
                to None.
        """
        if isinstance(result, Exception):
            show_result_dialog(result, self)
            return

        chart = result
        data_format = list(chart.queryv1("DATA_FORMAT").values())
        intent = "r" if getcfg("tc_add_ti3_relative") else "a"
        if not (
            chart[0].type.strip() == "GAMUT"
            and "RGB_R" in data_format
            and "RGB_G" in data_format
            and "RGB_B" in data_format
        ):
            as_ti3 = (
                "LAB_L" in data_format
                and "LAB_A" in data_format
                and "LAB_B" in data_format
            ) or (
                "XYZ_X" in data_format
                and "XYZ_Y" in data_format
                and "XYZ_Z" in data_format
            )
            if getcfg("tc_add_ti3_relative"):
                _ = chart.adapt()
            ti1, ti3, void = self.worker.chart_lookup(
                chart, profile, as_ti3, intent=intent, white_patches=False
            )
            if not ti1 or not ti3:
                return
            chart = ti1 if as_ti3 else ti3
        dataset = chart.queryi1("DATA")
        data_format = list(dataset.queryv1("DATA_FORMAT").values())
        # Returned CIE values are always either XYZ or Lab
        if "LAB_L" in data_format and "LAB_A" in data_format and "LAB_B" in data_format:
            cie = "Lab"
        else:
            cie = "XYZ"
        newdata = []
        rows = self.grid.GetSelectionRows()
        row = rows[-1] if rows else self.grid.GetNumberRows() - 1
        for i in dataset.DATA:
            if cie == "Lab":
                (
                    dataset.DATA[i]["XYZ_X"],
                    dataset.DATA[i]["XYZ_Y"],
                    dataset.DATA[i]["XYZ_Z"],
                ) = colormath.Lab2XYZ(
                    dataset.DATA[i]["LAB_L"],
                    dataset.DATA[i]["LAB_A"],
                    dataset.DATA[i]["LAB_B"],
                    scale=100,
                )
            if intent == "r":
                (
                    dataset.DATA[i]["XYZ_X"],
                    dataset.DATA[i]["XYZ_Y"],
                    dataset.DATA[i]["XYZ_Z"],
                ) = colormath.adapt(
                    dataset.DATA[i]["XYZ_X"],
                    dataset.DATA[i]["XYZ_Y"],
                    dataset.DATA[i]["XYZ_Z"],
                    "D50",
                    list(profile.tags.wtpt.values()),
                )
            entry = {"SAMPLE_ID": row + 2 + i}
            for label in ("RGB_R", "RGB_G", "RGB_B", "XYZ_X", "XYZ_Y", "XYZ_Z"):
                entry[label] = round(dataset.DATA[i][label], 4)
            newdata.append(entry)
        self.tc_add_data(row, newdata)
        self.grid.select_row(row + len(newdata))

    def tc_add_ti3(self, chart, img=None, use_gamut=True, profile=None):
        """Add a ti3 file or image to the test chart.

        Args:
            chart (str): Path to the ti3 file or image.
            img (wx.Image, optional): Image object if chart is an image.
                Defaults to None.
            use_gamut (bool, optional): Whether to use tiffgamut for
                processing. Defaults to True.
            profile (ICCProfile, optional): Preconditioning profile. Defaults
                to None.

        Returns:
            str: Path to the processed ti3 file or image.
        """
        if img:
            cwd = self.worker.create_tempdir()
            if isinstance(cwd, Exception):
                return cwd
            size = 70.0
            scale = math.sqrt((img.Width * img.Height) / (size * size))
            w = round(img.Width / scale)
            h = round(img.Height / scale)
            loresimg = img.Scale(w, h, wx.IMAGE_QUALITY_NORMAL)
            if loresimg.CountColours() < img.CountColours(size * size):
                # Assume a photo
                quality = wx.IMAGE_QUALITY_HIGH
            else:
                # Assume a target
                quality = wx.IMAGE_QUALITY_NORMAL
            ext = os.path.splitext(chart)[1]
            if ext.lower() in (".tif", ".tiff") or (
                self.worker.argyll_version >= [1, 4]
                and ext.lower() in (".jpeg", ".jpg")
            ):
                imgpath = chart
            else:
                imgpath = os.path.join(cwd, "image.tif")
                img.SaveFile(imgpath, wx.BITMAP_TYPE_TIF)
            outpath = os.path.join(cwd, "imageout.tif")
            # Process image to get colors
            # Two ways to do this: Convert image using cctiff
            # or use tiffgamut
            # In both cases, a profile embedded in the image will be used
            # with the preconditioning profile as fallback if there is no
            # image profile
            if not use_gamut:
                # Use cctiff
                cmdname = "cctiff"
            else:
                # Use tiffgamut
                cmdname = "tiffgamut"
                gam = os.path.join(cwd, "image.gam")
            cmd = get_argyll_util(cmdname)
            if cmd:
                ppath = getcfg("tc_precond_profile")
                intent = "r" if getcfg("tc_add_ti3_relative") else "a"
                for n in range(2 if ppath else 1):
                    if use_gamut:
                        res = 10 if imgpath == chart else 1
                        args = [f"-d{res}", "-O", gam]
                    # if self.worker.argyll_version >= [1, 0, 4]:
                    # args.append("-f100")
                    else:
                        args = ["-a"]
                        if self.worker.argyll_version >= [1, 4]:
                            # Always save as TIFF
                            args.append("-fT")
                        elif self.worker.argyll_version >= [1, 1]:
                            # TIFF photometric encoding 1..n
                            args.append("-t1")
                        else:
                            # TIFF photometric encoding 1..n
                            args.append("-e1")
                    args.append(f"-i{intent}")
                    if n == 0:
                        # Try to use embedded profile
                        args.append(imgpath)
                        if not use_gamut:
                            # Target
                            args.append(f"-i{intent}")
                            args.append(ppath)
                    else:
                        # Fall back to preconditioning profile
                        args.append(ppath)
                    args.append(imgpath)
                    if not use_gamut:
                        args.append(outpath)
                    result = self.worker.exec_cmd(
                        cmd, ["-v", *args], capture_output=True, skip_scripts=True
                    )
                    if not result:
                        errors = "".join(self.worker.errors)
                        if (
                            "Error - Can't open profile in file" in errors
                            or "Error - Can't read profile" in errors
                        ):
                            # Try again?
                            continue
                    break
                if isinstance(result, Exception) or not result:
                    self.worker.wrapup(False)
                if isinstance(result, Exception):
                    return result
                if result:
                    if use_gamut:
                        chart = gam
                    else:
                        last_output_space = None
                        for line in self.worker.output:
                            if line.startswith("Output space ="):
                                last_output_space = line.split("=")[1].strip()
                        chart = outpath if last_output_space == "RGB" else imgpath
                else:
                    return Error("\n".join(self.worker.errors or self.worker.output))
            else:
                return Error(lang.getstr("argyll.util.not_found", cmdname))

            if not use_gamut:
                llevel = wx.Log.GetLogLevel()
                wx.Log.SetLogLevel(0)  # Suppress TIFF library related message popups
                try:
                    img = wx.Image(chart, wx.BITMAP_TYPE_ANY)
                    if not img.IsOk():
                        raise Error(lang.getstr("error.file_type_unsupported"))
                except Exception as exception:
                    return exception
                finally:
                    wx.Log.SetLogLevel(llevel)
                    self.worker.wrapup(False)
                if img.Width != w or img.Height != h:
                    img.Rescale(w, h, quality)
                # Select RGB colors and fill chart
                chart = [
                    "TI1    ",
                    "BEGIN_DATA_FORMAT",
                    "RGB_R RGB_G RGB_B",
                    "END_DATA_FORMAT",
                    "BEGIN_DATA",
                    "END_DATA",
                ]
                for y in range(h):
                    for x in range(w):
                        R, G, B = (
                            img.GetRed(x, y) / 2.55,
                            img.GetGreen(x, y) / 2.55,
                            img.GetBlue(x, y) / 2.55,
                        )
                        chart.insert(-1, f"{R:.4f} {G:.4f} {B:.4f}")
                chart = "\n".join(chart)

        try:
            chart = CGATS(chart)
            if not chart.queryv1("DATA_FORMAT"):
                raise CGATSError(
                    lang.getstr(
                        "error.testchart.missing_fields",
                        (chart.filename, "DATA_FORMAT"),
                    )
                )
        except (OSError, CGATSError) as exception:
            return exception
        finally:
            path = None
            if isinstance(chart, CGATS):
                if chart.filename:
                    path = chart.filename
            elif os.path.isfile(chart):
                path = chart
            if path and os.path.dirname(path) == self.worker.tempdir:
                self.worker.wrapup(False)
        if img:
            if use_gamut:
                threshold = 2
            else:
                threshold = 4
                try:
                    void, ti3, void = self.worker.chart_lookup(
                        chart,
                        profile,
                        intent=intent,
                        white_patches=False,
                        raise_exceptions=True,
                    )
                except Exception as exception:
                    return exception
                if ti3:
                    chart = ti3
                else:
                    return Error(
                        lang.getstr("error.generic", (-1, lang.getstr("unknown")))
                    )
            colorsets = {}
            weights = {}
            demph = getcfg("tc_dark_emphasis")
            # Select Lab color
            data = chart.queryv1("DATA")
            for sample in data.values():
                if not use_gamut:
                    RGB = sample["RGB_R"], sample["RGB_G"], sample["RGB_B"]
                L, a, b = (sample["LAB_L"], sample["LAB_A"], sample["LAB_B"])
                color = round(L / 10), round(a / 15), round(b / 15)
                if color not in colorsets:
                    weights[color] = 0
                    colorsets[color] = []
                if L >= 50:
                    weights[color] += L / 50 - demph
                else:
                    weights[color] += L / 50 + demph
                colorsets[color].append((L, a, b))
                if not use_gamut:
                    colorsets[color][-1] += RGB
            # Fill chart
            data_format = "LAB_L LAB_A LAB_B"
            if not use_gamut:
                data_format += " RGB_R RGB_G RGB_B"
            chart = [
                "GAMUT  ",
                "BEGIN_DATA_FORMAT",
                data_format,
                "END_DATA_FORMAT",
                "BEGIN_DATA",
                "END_DATA",
            ]
            weight = bool(
                [
                    color
                    for color in iter(colorsets.keys())
                    if weights[color] >= threshold
                ]
            )
            for color in colorsets:
                colors = colorsets[color]
                if weight and weights[color] < threshold:
                    continue
                L, a, b = 0, 0, 0
                R, G, B = 0, 0, 0
                for v in colors:
                    L += v[0]
                    a += v[1]
                    b += v[2]
                    if len(v) == 6:
                        R += v[3]
                        G += v[4]
                        B += v[5]
                L /= len(colors)
                a /= len(colors)
                b /= len(colors)
                R /= len(colors)
                G /= len(colors)
                B /= len(colors)
                chart.insert(-1, f"{L:.4f} {a:.4f} {b:.4f}")
                if not use_gamut:
                    chart[-2] += f" {R:.4f} {G:.4f} {B:.4f}"

            chart = CGATS("\n".join(chart))
        else:
            chart.fix_device_values_scaling()

        return chart

    def tc_add_ti3_relative_handler(self, event):
        """Handle the checkbox for adding TI3 relative to the preconditioned chart.

        Args:
            event (wx.Event): The event triggered by the checkbox.
        """
        setcfg("tc_add_ti3_relative", int(self.add_ti3_relative_cb.GetValue()))

    def tc_angle_handler(self, event=None):
        """Handle the angle slider and integer control for test chart generation.

        Args:
            event (wx.Event): The event triggered by the slider or integer control.
        """
        if event.GetId() == self.tc_angle_slider.GetId():
            self.tc_angle_intctrl.SetValue(self.tc_angle_slider.GetValue())
        else:
            self.tc_angle_slider.SetValue(self.tc_angle_intctrl.GetValue())
        setcfg("tc_angle", self.tc_angle_intctrl.GetValue() / 10000.0)

    def tc_multi_bcc_cb_handler(self, event=None):
        """Handle the checkbox for multi BCC steps in test chart generation.

        Args:
            event (wx.Event): The event triggered by the checkbox.
        """
        setcfg("tc_multi_bcc", int(self.tc_multi_bcc_cb.GetValue()))
        self.tc_multi_steps_handler2()

    def tc_precond_handler(self, event=None):
        """Handle the checkbox for test chart preconditioning.

        Args:
            event (wx.Event): The event triggered by the checkbox.
        """
        setcfg("tc_precond", int(self.tc_precond.GetValue()))
        self.tc_adaption_slider.SetValue(
            int((1 if getcfg("tc_precond") else DEFAULTS["tc_adaption"]) * 100)
        )
        self.tc_adaption_handler(self.tc_adaption_slider)
        self.tc_algo_handler()

    def tc_precond_profile_handler(self, event=None):
        """Handle the test chart preconditioning profile selection.

        Args:
            event (wx.Event): The event triggered by the control.
        """
        tc_precond_enable = bool(self.tc_precond_profile.GetPath())
        self.tc_precond.Enable(tc_precond_enable)
        setcfg("tc_precond_profile", self.tc_precond_profile.GetPath())
        self.tc_algo_handler()

    def tc_precond_profile_current_ctrl_handler(self, event):
        """Handle the current profile path control for test chart preconditioning.

        Args:
            event (wx.Event): The event triggered by the control.
        """
        profile_path = get_current_profile_path(True, True)
        if profile_path:
            self.tc_precond_profile.SetPath(profile_path)
            self.tc_precond_profile_handler()
        else:
            show_result_dialog(
                Error(
                    lang.getstr(
                        "display_profile.not_detected", get_display_name(None, True)
                    )
                ),
                self,
            )

    def tc_filter_handler(self, event=None):
        """Handle the checkbox for test chart filtering.

        Args:
            event (wx.Event): The event triggered by the checkbox.
        """
        setcfg("tc_filter", int(self.tc_filter.GetValue()))
        setcfg("tc_filter_L", self.tc_filter_L.GetValue())
        setcfg("tc_filter_a", self.tc_filter_a.GetValue())
        setcfg("tc_filter_b", self.tc_filter_b.GetValue())
        setcfg("tc_filter_rad", self.tc_filter_rad.GetValue())

    def tc_vrml_black_offset_ctrl_handler(self, event):
        """Handle the VRML black offset control.

        Args:
            event (wx.Event): The event triggered by the control.
        """
        setcfg("tc_vrml_black_offset", self.tc_vrml_black_offset_intctrl.GetValue())

    def tc_vrml_compress_handler(self, event):
        """Handle the checkbox for VRML compression.

        Args:
            event (wx.Event): The event triggered by the checkbox.
        """
        setcfg("vrml.compress", int(self.tc_vrml_compress_cb.GetValue()))

    def tc_vrml_handler(self, event=None):
        """Handle the VRML controls and update the configuration."""
        d = self.tc_vrml_device.GetValue()
        l = self.tc_vrml_cie.GetValue()
        if event:
            setcfg("tc_vrml_device", int(d))
            setcfg("tc_vrml_cie", int(l))
            setcfg(
                "tc_vrml_cie_colorspace",
                self.tc_vrml_cie_colorspace_ctrl.GetStringSelection(),
            )
            setcfg(
                "tc_vrml_device_colorspace",
                self.tc_vrml_device_colorspace_ctrl.GetStringSelection(),
            )
        self.vrml_save_as_btn.Enable(hasattr(self, "ti1") and (d or l))
        self.view_3d_format_btn.Enable(hasattr(self, "ti1") and (d or l))

    def tc_vrml_use_D50_handler(self, event):
        """Handle the checkbox for using D50 in VRML."""
        setcfg("tc_vrml_use_D50", int(self.tc_vrml_use_D50_cb.GetValue()))

    def tc_update_controls(self):
        """Update the test chart controls with the current configuration."""
        self.tc_algo.SetStringSelection(
            self.tc_algos_ab.get(
                getcfg("tc_algo"), self.tc_algos_ab.get(DEFAULTS["tc_algo"])
            )
        )
        self.tc_white_patches.SetValue(getcfg("tc_white_patches"))
        if self.worker.argyll_version >= [1, 6]:
            self.tc_black_patches.SetValue(getcfg("tc_black_patches"))
        self.tc_single_channel_patches.SetValue(getcfg("tc_single_channel_patches"))
        self.tc_gray_patches.SetValue(getcfg("tc_gray_patches"))
        if getcfg("tc_multi_bcc_steps"):
            setcfg("tc_multi_bcc", 1)
            self.tc_multi_steps.SetValue(getcfg("tc_multi_bcc_steps"))
        else:
            setcfg("tc_multi_bcc", 0)
            self.tc_multi_steps.SetValue(getcfg("tc_multi_steps"))
        if hasattr(self, "tc_multi_bcc_cb"):
            self.tc_multi_bcc_cb.SetValue(bool(getcfg("tc_multi_bcc")))
        self.tc_multi_steps_handler2()
        self.tc_fullspread_patches.SetValue(getcfg("tc_fullspread_patches"))
        self.tc_angle_slider.SetValue(int(getcfg("tc_angle") * 10000))
        self.tc_angle_handler(self.tc_angle_slider)
        self.tc_adaption_slider.SetValue(int(getcfg("tc_adaption") * 100))
        self.tc_adaption_handler(self.tc_adaption_slider)
        if self.worker.argyll_version == [
            1,
            1,
            "RC2",
        ] or self.worker.argyll_version >= [1, 1]:
            self.tc_gamma_floatctrl.SetValue(getcfg("tc_gamma"))
        if self.worker.argyll_version >= [1, 3, 3]:
            self.tc_neutral_axis_emphasis_slider.SetValue(
                int(getcfg("tc_neutral_axis_emphasis") * 100)
            )
            self.tc_neutral_axis_emphasis_handler(self.tc_neutral_axis_emphasis_slider)
        if self.worker.argyll_version >= [1, 6, 2]:
            self.tc_dark_emphasis_slider.SetValue(int(getcfg("tc_dark_emphasis") * 100))
            self.tc_dark_emphasis_handler(self.tc_dark_emphasis_slider)
        self.tc_precond_profile.SetPath(getcfg("tc_precond_profile"))
        self.tc_filter.SetValue(bool(int(getcfg("tc_filter"))))
        self.tc_filter_L.SetValue(getcfg("tc_filter_L"))
        self.tc_filter_a.SetValue(getcfg("tc_filter_a"))
        self.tc_filter_b.SetValue(getcfg("tc_filter_b"))
        self.tc_filter_rad.SetValue(getcfg("tc_filter_rad"))
        self.tc_vrml_cie.SetValue(bool(int(getcfg("tc_vrml_cie"))))
        self.tc_vrml_cie_colorspace_ctrl.SetSelection(
            config.VALID_VALUES["tc_vrml_cie_colorspace"].index(
                getcfg("tc_vrml_cie_colorspace")
            )
        )
        self.tc_vrml_device_colorspace_ctrl.SetSelection(
            config.VALID_VALUES["tc_vrml_device_colorspace"].index(
                getcfg("tc_vrml_device_colorspace")
            )
        )
        self.tc_vrml_device.SetValue(bool(int(getcfg("tc_vrml_device"))))
        self.tc_vrml_black_offset_intctrl.SetValue(getcfg("tc_vrml_black_offset"))
        self.tc_vrml_use_D50_cb.SetValue(bool(getcfg("tc_vrml_use_D50")))
        self.tc_vrml_handler()
        self.tc_vrml_compress_cb.SetValue(bool(getcfg("vrml.compress")))
        self.add_ti3_relative_cb.SetValue(bool(getcfg("tc_add_ti3_relative")))
        self.tc_enable_sort_controls()

    def tc_check(self, event=None):
        """Validate and update test chart controls."""
        white_patches = self.tc_white_patches.GetValue()
        self.tc_amount = self.tc_get_total_patches(white_patches)
        self.preview_btn.Enable(
            self.tc_amount
            - max(0, self.tc_get_white_patches())
            - max(0, self.tc_get_black_patches())
            >= 8
        )
        self.clear_btn.Enable(hasattr(self, "ti1"))
        self.tc_save_check()
        self.save_as_btn.Enable(hasattr(self, "ti1"))
        self.export_btn.Enable(hasattr(self, "ti1"))
        self.tc_vrml_handler()
        self.tc_enable_add_precond_controls()
        self.tc_enable_sort_controls()
        self.tc_set_default_status()

    def tc_save_check(self):
        """Check if the save button should be enabled based on the current state."""
        self.save_btn.Enable(
            hasattr(self, "ti1")
            and self.ti1.modified
            and bool(self.ti1.filename)
            and os.path.exists(self.ti1.filename)
            and get_data_path(os.path.join("ref", os.path.basename(self.ti1.filename)))
            != self.ti1.filename
            and get_data_path(os.path.join("ti1", os.path.basename(self.ti1.filename)))
            != self.ti1.filename
        )

    def tc_save_cfg(self):
        """Save the current test chart configuration to the settings."""
        setcfg("tc_white_patches", self.tc_white_patches.GetValue())
        if self.worker.argyll_version >= [1, 6]:
            setcfg("tc_black_patches", self.tc_black_patches.GetValue())
        setcfg("tc_single_channel_patches", self.tc_single_channel_patches.GetValue())
        setcfg("tc_gray_patches", self.tc_gray_patches.GetValue())
        setcfg("tc_multi_steps", self.tc_multi_steps.GetValue())
        setcfg("tc_fullspread_patches", self.tc_fullspread_patches.GetValue())
        tc_algo = self.tc_algos_ba[self.tc_algo.GetStringSelection()]
        setcfg("tc_algo", tc_algo)
        setcfg("tc_angle", self.tc_angle_intctrl.GetValue() / 10000.0)
        setcfg("tc_adaption", self.tc_adaption_intctrl.GetValue() / 100.0)
        tc_precond_enable = tc_algo in ("I", "Q", "R", "t") or (
            tc_algo == "" and self.tc_adaption_slider.GetValue() > 0
        )
        if tc_precond_enable:
            setcfg("tc_precond", int(self.tc_precond.GetValue()))
        setcfg("tc_precond_profile", self.tc_precond_profile.GetPath())
        setcfg("tc_filter", int(self.tc_filter.GetValue()))
        setcfg("tc_filter_L", self.tc_filter_L.GetValue())
        setcfg("tc_filter_a", self.tc_filter_a.GetValue())
        setcfg("tc_filter_b", self.tc_filter_b.GetValue())
        setcfg("tc_filter_rad", self.tc_filter_rad.GetValue())
        setcfg("tc_vrml_cie", int(self.tc_vrml_cie.GetValue()))
        setcfg("tc_vrml_device", int(self.tc_vrml_device.GetValue()))

    def tc_preview_handler(self, event=None, path=None):
        """Handle the preview button click event.

        Args:
            event (wx.Event): The event that triggered the preview.
            path (str): Optional path to save the preview.
        """
        if self.worker.is_working():
            return

        fullspread_patches = getcfg("tc_fullspread_patches")
        single_patches = getcfg("tc_single_channel_patches")
        gray_patches = getcfg("tc_gray_patches")
        multidim_patches = getcfg("tc_multi_steps")
        multidim_bcc_patches = getcfg("tc_multi_bcc_steps")
        wkwargs = {}
        if (
            fullspread_patches
            and (
                single_patches > 2
                or gray_patches > 2
                or multidim_patches > 2
                or multidim_bcc_patches
            )
            and wx.GetKeyState(wx.WXK_SHIFT)
        ):
            dlg = ConfirmDialog(
                self,
                -1,
                lang.getstr("testchart.create"),
                lang.getstr("testchart.separate_fixed_points"),
                ok=lang.getstr("ok"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, APPNAME + "-testchart-editor"),
            )
            dlg.sizer3.Add((1, 4))

            # single
            if single_patches > 2:
                dlg.single = wx.CheckBox(dlg, -1, lang.getstr("tc.single"))
                dlg.sizer3.Add(dlg.single, 1, flag=wx.TOP, border=4)
                dlg.single.Value = True

            # gray
            if gray_patches > 2:
                dlg.gray_patches = wx.CheckBox(dlg, -1, lang.getstr("tc.gray"))
                dlg.sizer3.Add(dlg.gray, 1, flag=wx.TOP, border=4)
                dlg.gray.Value = True

            # multidim
            if multidim_patches > 2 or multidim_bcc_patches:
                dlg.multidim = wx.CheckBox(dlg, -1, lang.getstr("tc.multidim"))
                dlg.sizer3.Add(dlg.multidim, 1, flag=wx.TOP, border=4)
                dlg.multidim.Value = True

            dlg.sizer0.SetSizeHints(dlg)
            dlg.sizer0.Layout()
            choice = dlg.ShowModal()
            for name in ("single", "gray", "multidim"):
                if hasattr(dlg, name):
                    wkwargs[name] = getattr(dlg, name).Value
            dlg.Destroy()
            if choice == wx.ID_CANCEL:
                return

        if not self.tc_check_save_ti1():
            return
        if not check_set_argyll_bin():
            return

        print("-" * 80)
        print(lang.getstr("testchart.create"))
        self.worker.interactive = False
        self.worker.start(
            self.tc_preview,
            self.tc_create,
            cargs=(path,),
            wargs=(),
            wkwargs=wkwargs,
            progress_msg=lang.getstr("testchart.create"),
            parent=self,
            progress_start=500,
        )

    def tc_preview_update(self, startindex):
        """Update the preview grid with the current test chart data.

        Args:
            startindex (int): The index from which to start updating the preview.
        """
        if not hasattr(self, "preview"):
            return
        numcols = self.preview.GetNumberCols()
        startrow = startindex / numcols
        startcol = startindex % numcols
        i = 0
        row = startrow
        numrows = self.preview.GetNumberRows()
        neededrows = math.ceil(float(self.grid.GetNumberRows()) / numcols) - numrows
        if neededrows > 0:
            self.preview.AppendRows(neededrows)
        while True:
            cols = range(startcol, numcols) if row == startrow else range(numcols)
            for col in cols:
                if startindex + i < self.grid.GetNumberRows():
                    color = self.grid.GetCellBackgroundColour(startindex + i, 3)
                    textcolor = self.grid.GetCellTextColour(startindex + i, 3)
                    value = self.grid.GetCellValue(startindex + i, 3)
                else:
                    color = self.preview.GetDefaultCellBackgroundColour()
                    textcolor = self.preview.GetDefaultCellTextColour()
                    value = ""
                self.preview.SetCellBackgroundColour(row, col, color)
                self.preview.SetCellTextColour(row, col, textcolor)
                self.preview.SetCellValue(row, col, value)
                i += 1
            row += 1
            if startindex + i >= self.grid.GetNumberRows():
                break
        if row < self.preview.GetNumberRows():
            self.preview.DeleteRows(row, self.preview.GetNumberRows() - row)

    def tc_clear_handler(self, event):
        """Clear the test chart editor.

        Args:
            event (wx.Event): The event that triggered the clear.
        """
        self.tc_check_save_ti1()

    def tc_clear(self, clear_ti1=True):
        """Clear the test chart editor.

        Args:
            clear_ti1 (bool): If True, clear the ti1 object and reset the
                window title.
        """
        grid = self.grid
        if grid.GetNumberRows() > 0:
            grid.DeleteRows(0, grid.GetNumberRows())
        if grid.GetNumberCols() > 0:
            grid.DeleteCols(0, grid.GetNumberCols())
        grid.Refresh()
        self.separator.Hide()
        self.sizer.Layout()
        if hasattr(self, "preview"):
            if self.preview.GetNumberRows() > 0:
                self.preview.DeleteRows(0, self.preview.GetNumberRows())
            if self.preview.GetNumberCols() > 0:
                self.preview.DeleteCols(0, self.preview.GetNumberCols())
            self.preview.Refresh()
        if clear_ti1:
            if hasattr(self, "ti1"):
                del self.ti1
            self.tc_update_controls()
            self.tc_check()
            # UGLY HACK: This 'print' call fixes a GTK assertion and
            # segfault under Arch Linux when setting the window title
            print("")
            self.SetTitle(lang.getstr("testchart.edit"))

    def tc_export_handler(self, event):
        """Export the test chart to a file.

        Args:
            event (wx.Event): The event that triggered the export.
        """
        if not hasattr(self, "ti1"):
            return
        path = None
        (defaultDir, defaultFile) = (
            get_verified_path("last_testchart_export_path")[0],
            os.path.basename(
                os.path.splitext(
                    self.ti1.filename or DEFAULTS["last_testchart_export_path"]
                )[0]
            ),
        )
        dlg = wx.FileDialog(
            self,
            lang.getstr("export"),
            defaultDir=defaultDir,
            defaultFile=defaultFile,
            # Disable JPEG as it introduces slight color errors
            wildcard=lang.getstr(  # lang.getstr("filetype.jpg") + "|*.jpg|" +
                "filetype.png"
            )
            + " (8-bit)|*.png|"
            + lang.getstr("filetype.png")
            + " (16-bit)|*.png|"
            + lang.getstr("filetype.tif")
            + " (8-bit)|*.tif|"
            + lang.getstr("filetype.tif")
            + " (16-bit)|*.tif|"
            + "DPX|*.dpx|"
            + "CSV (0.0..100.0)|*.csv|"
            + "CSV (0..255)|*.csv|"
            + "CSV (0..1023)|*.csv",
            style=wx.SAVE | wx.OVERWRITE_PROMPT,
        )
        dlg.Center(wx.BOTH)
        if dlg.ShowModal() == wx.ID_OK:
            filter_index = dlg.GetFilterIndex()
            path = dlg.GetPath()
        dlg.Destroy()
        if path:
            if not waccess(path, os.W_OK):
                show_result_dialog(
                    Error(lang.getstr("error.access_denied.write", path)), self
                )
                return
            setcfg("last_testchart_export_path", path)
            self.writecfg()
        else:
            return
        if filter_index < 5:
            # Image format
            scale = getcfg("app.dpi") / config.get_default_dpi()
            scale = max(scale, 1)
            dlg = ConfirmDialog(
                self,
                title=lang.getstr("export"),
                msg=lang.getstr("testchart.export.repeat_patch"),
                ok=lang.getstr("ok"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, APPNAME + "-testchart-editor"),
            )
            sizer = wx.BoxSizer(wx.HORIZONTAL)
            dlg.sizer3.Add(sizer, 0, flag=wx.TOP | wx.ALIGN_LEFT, border=12)
            intctrl = wx.SpinCtrl(
                dlg,
                -1,
                size=(95 * scale, -1),
                min=config.VALID_RANGES["tc_export_repeat_patch_max"][0],
                max=config.VALID_RANGES["tc_export_repeat_patch_max"][1],
                value=str(getcfg("tc_export_repeat_patch_max")),
            )
            sizer.Add(intctrl, 0, flag=wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=4)
            sizer.Add(
                wx.StaticText(dlg, -1, "× " + lang.getstr("max")),  # noqa: RUF001
                0,
                flag=wx.RIGHT | wx.ALIGN_CENTER_VERTICAL,
                border=12,
            )
            intctrl2 = wx.SpinCtrl(
                dlg,
                -1,
                size=(95 * scale, -1),
                min=config.VALID_RANGES["tc_export_repeat_patch_min"][0],
                max=config.VALID_RANGES["tc_export_repeat_patch_min"][1],
                value=str(getcfg("tc_export_repeat_patch_min")),
            )
            sizer.Add(intctrl2, 0, flag=wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=4)
            sizer.Add(
                wx.StaticText(dlg, -1, "× " + lang.getstr("min")),  # noqa: RUF001
                0,
                flag=wx.RIGHT | wx.ALIGN_CENTER_VERTICAL,
                border=12,
            )
            dlg.sizer0.SetSizeHints(dlg)
            dlg.sizer0.Layout()
            result = dlg.ShowModal()
            repeatmax = intctrl.GetValue()
            repeatmin = intctrl2.GetValue()
            dlg.Destroy()
            if result != wx.ID_OK:
                return
            setcfg("tc_export_repeat_patch_max", repeatmax)
            setcfg("tc_export_repeat_patch_min", repeatmin)
            self.writecfg()
            DEFAULTS["size.measureframe"] = get_default_size()
            self.display_size = wx.DisplaySize()
        if path:
            self.worker.start(
                lambda result: None,
                self.tc_export,
                wargs=(path, filter_index),
                wkwargs={},
                progress_msg=lang.getstr("export"),
                parent=self,
                progress_start=500,
                fancy=False,
            )

    def tc_export(self, path, filter_index):
        """Export the test chart to the given path.

        Args:
            path (str): The file path to export the test chart to.
            filter_index (int): Index of the selected filter format.
        """
        if filter_index < 5:
            # Image format
            self.tc_export_subroutine(path, filter_index)
            return

        # CSV
        with open(path, "w") as csvfile:
            self.tc_export_subroutine(csv.writer(csvfile), filter_index)

    def tc_export_subroutine(self, target, filter_index, allow_gaps=False):
        """Export the test chart to the given target.

        Args:
            target (str or csv.writer): The file path for image formats or a
                CSV writer object.
            filter_index (int): Index of the selected filter format.
            allow_gaps (bool): Whether to allow gaps in the timecode.
        """
        maxlen = len(self.ti1[0].DATA)
        if filter_index < 5:
            # Image format
            name, ext = (
                os.path.splitext(target)[0],
                {0: ".png", 1: ".png", 2: ".tif", 3: ".tif", 4: ".dpx"}[filter_index],
            )
            file_format = {".dpx": "DPX", ".png": "PNG", ".tif": "TIFF"}[ext]
            bitdepth = {0: 8, 1: 16, 2: 8, 3: 16, 4: 10}[filter_index]
            vscale = 2**bitdepth - 1
            repeatmax = getcfg("tc_export_repeat_patch_max")
            repeatmin = getcfg("tc_export_repeat_patch_min")
            maxcount = maxlen * repeatmax
            filename_format = f"{{:s}}-{{:0{len(str(maxcount))}d}}{{:s}}"
            count = 0
            secs = 0
            # Scale from screen dimensions to fixed 1080p viewport
            sw, sh = 1920, 1080
            x, y, size = [
                float(v) for v in getcfg("dimensions.measureframe").split(",")
            ]
            size *= DEFAULTS["size.measureframe"]
            displays = getcfg("displays")
            match = None
            display_no = getcfg("display.number") - 1
            if display_no in range(len(displays)):
                match = re.search(r"@ -?\d+, -?\d+, (\d+)x(\d+)", displays[display_no])
            if match:
                display_size = [int(item) for item in match.groups()]
            else:
                display_size = self.display_size
            w, h = [min(size / v, 1.0) for v in display_size]
            x = (display_size[0] - size) * x / display_size[0]
            y = (display_size[1] - size) * y / display_size[1]
            x, y, w, h = [max(v, 0) for v in (x, y, w, h)]
            x, w = [round(v * sw) for v in (x, w)]
            y, h = [round(v * sh) for v in (y, h)]
            dimensions = w, h
        else:
            # CSV
            vscale = {5: 100, 6: 255, 7: 1023}[filter_index]
        is_winnt6 = sys.platform == "win32" and sys.getwindowsversion() >= (6,)
        use_winnt6_symlinks = is_winnt6 and is_superuser()
        for i in range(maxlen):
            if self.worker.thread_abort:
                break
            self.worker.lastmsg.write(f"{100.0 / maxlen * (i + 1)}%\n")
            R, G, B = (
                self.ti1[0].DATA[i]["RGB_R"],
                self.ti1[0].DATA[i]["RGB_G"],
                self.ti1[0].DATA[i]["RGB_B"],
            )
            if not filter_index < 5:
                # CSV
                if vscale != 100:
                    # XXX: Careful when rounding floats!
                    # Incorrect: round(50 * 2.55) = 127 (127.499999)
                    # Correct: round(50 / 100.0 * 255) = 128 (127.5)
                    #
                    # Wouldn't it be better to use: round(50 * 255.0 / 100.0)
                    R, G, B = [round(v / 100.0 * vscale) for v in [R, G, B]]
                target.writerow([str(v) for v in [i, R, G, B]])
                continue
            # Image format
            X, Y, Z = colormath.RGB2XYZ(R / 100.0, G / 100.0, B / 100.0, scale=100.0)
            L, a, b = colormath.XYZ2Lab(X, Y, Z)
            # XXX: Careful when rounding floats!
            # Incorrect: int(round(50 * 2.55)) = 127 (127.499999)
            # Correct: int(round(50 / 100.0 * 255)) = 128 (127.5)
            color = (
                round(R / 100.0 * vscale),
                round(G / 100.0 * vscale),
                round(B / 100.0 * vscale),
            )
            count += 1
            filename = filename_format.format(name, count, ext)
            repeat = round(repeatmin + ((repeatmax - repeatmin) / 100.0 * (100 - L)))
            imfile.write(
                [[color]],
                filename,
                bitdepth,
                file_format,
                dimensions,
                {
                    "original_width": sw,
                    "original_height": sh,
                    "offset_x": x,
                    "offset_y": y,
                    "frame_position": count,
                    "frame_rate": 1,
                    "held_count": repeat if allow_gaps else 1,
                    "timecode": [
                        int(v)
                        for v in time.strftime("%H:%M:%S:00", time.gmtime(secs)).split(
                            ":"
                        )
                    ],
                },
            )
            secs += 1
            # print("RGB", R, G, B, "L* %.2f" % L, "repeat", repeat)
            if repeat > 1:
                if allow_gaps:
                    count += repeat - 1
                    secs += repeat - 1
                    continue
                for _ in range(repeat - 1):
                    count += 1
                    filecopyname = filename_format % (name, count, ext)
                    if file_format == "DPX":
                        imfile.write(
                            [[color]],
                            filecopyname,
                            bitdepth,
                            file_format,
                            dimensions,
                            {
                                "original_width": sw,
                                "original_height": sh,
                                "offset_x": x,
                                "offset_y": y,
                                "frame_position": count,
                                "frame_rate": 1,
                                "timecode": [
                                    int(v)
                                    for v in time.strftime(
                                        "%H:%M:%S:00", time.gmtime(secs)
                                    ).split(":")
                                ],
                            },
                        )
                    secs += 1
                    if file_format == "DPX":
                        continue
                    if os.path.isfile(filecopyname):
                        os.unlink(filecopyname)
                    if is_winnt6:
                        if use_winnt6_symlinks:
                            win32file.CreateSymbolicLink(
                                filecopyname, os.path.basename(filename), 0
                            )
                        else:
                            shutil.copyfile(filename, filecopyname)
                    else:
                        os.symlink(os.path.basename(filename), filecopyname)

    def tc_save_handler(self, event=None):
        """Save the testchart to its current filename.

        Args:
            event: The event that triggered this handler, if any.
        """
        self.tc_save_as_handler(event, path=self.ti1.filename)

    def tc_save_as_handler(self, event=None, path=None):
        """Open a file dialog to save the testchart.

        Args:
            event: The event that triggered this handler, if any.
            path (str): The path to save the testchart to. If None, a dialog
                will be opened.

        Returns:
            bool: True if the testchart was saved successfully, False otherwise.
        """
        checkoverwrite = True
        if path is None or (event and not os.path.isfile(path)):
            path = None
            defaultDir = get_verified_path("last_ti1_path")[0]
            if hasattr(self, "ti1") and self.ti1.filename:
                if os.path.isfile(self.ti1.filename):
                    defaultDir = os.path.dirname(self.ti1.filename)
                defaultFile = os.path.basename(self.ti1.filename)
            else:
                defaultFile = os.path.basename(config.DEFAULTS["last_ti1_path"])
            dlg = wx.FileDialog(
                self,
                lang.getstr("save_as"),
                defaultDir=defaultDir,
                defaultFile=defaultFile,
                wildcard=lang.getstr("filetype.ti1") + "|*.ti1",
                style=wx.SAVE | wx.OVERWRITE_PROMPT,
            )
            dlg.Center(wx.BOTH)
            if dlg.ShowModal() == wx.ID_OK:
                path = dlg.GetPath()
            dlg.Destroy()
            if path:
                filename, ext = os.path.splitext(path)
                if ext.lower() != ".ti1":
                    path += ".ti1"
                else:
                    checkoverwrite = False
        if path:
            if not waccess(path, os.W_OK):
                show_result_dialog(
                    Error(lang.getstr("error.access_denied.write", path)), self
                )
                return None
            if checkoverwrite and os.path.isfile(path):
                dlg = ConfirmDialog(
                    self,
                    msg=lang.getstr("dialog.confirm_overwrite", (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 None
            setcfg("last_ti1_path", path)
            try:
                with open(path, "wb") as file_:
                    file_.write(bytes(self.ti1))
                self.ti1.filename = path
                self.ti1.root.setmodified(False)
                if not self.IsBeingDeleted():
                    # UGLY HACK: This 'print' call fixes a GTK assertion and
                    # segfault under Arch Linux when setting the window title
                    print("")
                    self.SetTitle(
                        lang.getstr("testchart.edit").rstrip(".")
                        + ": "
                        + os.path.basename(path)
                    )
            except Exception as exception:
                handle_error(
                    Error(f"Error - testchart could not be saved: {exception!s}"),
                    parent=self,
                )
            else:
                if self.Parent:
                    if path != getcfg(self.cfg) and self.parent_set_chart_methodname:
                        dlg = ConfirmDialog(
                            self,
                            msg=lang.getstr("testchart.confirm_select"),
                            ok=lang.getstr("testchart.select"),
                            cancel=lang.getstr("testchart.dont_select"),
                            bitmap=get_icon(32, "dialog-question"),
                        )
                        result = dlg.ShowModal()
                        dlg.Destroy()
                        if result == wx.ID_OK:
                            setcfg(self.cfg, path)
                            self.writecfg()
                    if path == getcfg(self.cfg) and self.parent_set_chart_methodname:
                        getattr(self.Parent, self.parent_set_chart_methodname)(path)
                if not self.IsBeingDeleted():
                    self.save_btn.Disable()
                return True
        return False

    def tc_view_3d(self, event):
        """View the 3D representation of the testchart.

        Args:
            event (wx.Event): The event that triggered this method, if any.
        """
        if (
            self.ti1.filename
            and not (
                self.worker.tempdir
                and self.ti1.filename.startswith(self.worker.tempdir)
            )
            and waccess(os.path.dirname(self.ti1.filename), os.W_OK)
        ):
            regenerate = self.ti1.modified
            paths = self.tc_save_3d(
                os.path.splitext(self.ti1.filename)[0], regenerate=regenerate
            )
            if not regenerate and os.path.isfile(self.ti1.filename):
                # Check if the testchart is newer than the 3D file(s)
                ti1_mtime = os.stat(self.ti1.filename).st_mtime
                for path in paths:
                    if os.stat(path).st_mtime < ti1_mtime:
                        regenerate = True
                        break
                if regenerate:
                    paths = self.tc_save_3d(
                        os.path.splitext(self.ti1.filename)[0], regenerate=True
                    )
        else:
            paths = self.tc_save_3d_as_handler(None)
        for path in paths:
            launch_file(path)

    def tc_save_3d_as_handler(self, event):
        """Open a file dialog to save the 3D representation of the testchart.

        Args:
            event: The event that triggered this handler, if any.

        Returns:
            list: A list of paths to the saved 3D files.
        """
        path = None
        paths = []
        if (
            hasattr(self, "ti1")
            and self.ti1.filename
            and os.path.isfile(self.ti1.filename)
        ):
            defaultDir = os.path.dirname(self.ti1.filename)
            defaultFile = self.ti1.filename
        else:
            defaultDir = get_verified_path("last_vrml_path")[0]
            defaultFile = DEFAULTS["last_vrml_path"]
        view_3d_format = getcfg("3d.format")
        if view_3d_format == "HTML":
            formatext = ".html"
        elif view_3d_format == "VRML":
            formatext = ".wrz" if getcfg("vrml.compress") else ".wrl"
        else:
            formatext = ".x3d"
        defaultFile = os.path.splitext(os.path.basename(defaultFile))[0] + formatext
        dlg = wx.FileDialog(
            self,
            lang.getstr("save_as"),
            defaultDir=defaultDir,
            defaultFile=defaultFile,
            wildcard=lang.getstr("view.3d") + "|*" + formatext,
            style=wx.SAVE | wx.OVERWRITE_PROMPT,
        )
        dlg.Center(wx.BOTH)
        if dlg.ShowModal() == wx.ID_OK:
            path = dlg.GetPath()
        dlg.Destroy()
        if path:
            if not waccess(path, os.W_OK):
                show_result_dialog(
                    Error(lang.getstr("error.access_denied.write", path)), self
                )
                return []
            filename, ext = os.path.splitext(path)
            if ext.lower() != formatext:
                path += formatext
            setcfg("last_vrml_path", path)
            paths = self.tc_save_3d(filename)
        return paths

    def tc_save_3d(self, filename, regenerate=True):
        """Save the 3D representation of the testchart.

        Args:
            filename (str): The base filename to save the 3D files to.
            regenerate (bool): Whether to regenerate the 3D files if they
                already exist. Defaults to True.

        Returns:
            list: A list of paths to the saved 3D files.
        """
        paths = []
        view_3d_format = getcfg("3d.format")
        if view_3d_format == "VRML":
            formatext = ".wrz" if getcfg("vrml.compress") else ".wrl"
        else:
            formatext = ".x3d"
            if view_3d_format == "HTML":
                formatext += ".html"
        if not getcfg("tc_vrml_device") and getcfg("tc_vrml_cie"):
            return paths
        colorspaces = []
        if getcfg("tc_vrml_device"):
            colorspaces.append(getcfg("tc_vrml_device_colorspace"))
        if getcfg("tc_vrml_cie"):
            colorspaces.append(getcfg("tc_vrml_cie_colorspace"))
        for colorspace in colorspaces:
            path = filename + " " + colorspace + formatext
            if os.path.exists(path):
                if regenerate:
                    dlg = ConfirmDialog(
                        self,
                        msg=lang.getstr("dialog.confirm_overwrite", (path)),
                        ok=lang.getstr("overwrite"),
                        cancel=lang.getstr("cancel"),
                        bitmap=get_icon(32, "dialog-warning"),
                    )
                    result = dlg.ShowModal()
                    dlg.Destroy()
                else:
                    result = wx.ID_CANCEL
                if result != wx.ID_OK:
                    paths.append(path)
                    continue
            try:
                self.ti1[0].export_3d(
                    path,
                    colorspace,
                    RGB_black_offset=getcfg("tc_vrml_black_offset"),
                    normalize_RGB_white=getcfg("tc_vrml_use_D50"),
                    compress=formatext == ".wrz",
                    file_format=view_3d_format,
                )
            except Exception as exception:
                handle_error(
                    UserWarning(f"Warning - 3D file could not be saved: {exception}"),
                    parent=self,
                )
            else:
                paths.append(path)
        return paths

    def tc_check_save_ti1(self, clear=True):
        """Check if the current testchart needs to be saved before closing.

        Args:
            clear (bool): If True, clear the testchart after saving or discarding.

        Returns:
            bool: True if the testchart can be closed, False otherwise.
        """
        if not hasattr(self, "ti1"):
            return True
        if (
            self.ti1.root.modified
            or not self.ti1.filename
            or not os.path.exists(self.ti1.filename)
        ):
            if self.save_btn.Enabled:
                ok = lang.getstr("save")
            else:
                ok = lang.getstr("save_as")
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("testchart.save_or_discard"),
                ok=ok,
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-warning"),
            )
            if self.IsBeingDeleted():
                dlg.buttonpanel.Hide(0)
            if self.save_btn.Enabled:
                dlg.save_as = wx.Button(dlg.buttonpanel, -1, lang.getstr("save_as"))
                ID_SAVE_AS = dlg.save_as.GetId()
                dlg.Bind(wx.EVT_BUTTON, dlg.OnClose, id=ID_SAVE_AS)
                dlg.sizer2.Add((12, 12))
                dlg.sizer2.Add(dlg.save_as)
            else:
                ID_SAVE_AS = wx.ID_OK
            dlg.discard = wx.Button(
                dlg.buttonpanel, -1, lang.getstr("testchart.discard")
            )
            ID_DISCARD = dlg.discard.GetId()
            dlg.Bind(wx.EVT_BUTTON, dlg.OnClose, id=ID_DISCARD)
            dlg.sizer2.Add((12, 12))
            dlg.sizer2.Add(dlg.discard)
            dlg.buttonpanel.Layout()
            dlg.sizer0.SetSizeHints(dlg)
            dlg.sizer0.Layout()
            result = dlg.ShowModal()
            dlg.Destroy()
            if result in (wx.ID_OK, ID_SAVE_AS):
                path = None if result == ID_SAVE_AS else self.ti1.filename
                if not self.tc_save_as_handler(True, path):
                    return False
            elif result == wx.ID_CANCEL:
                return False
            clear = True
        if clear and not self.IsBeingDeleted():
            self.tc_clear()
        return True

    def tc_close_handler(self, event=None):
        """Handle the closing of the testchart editor.

        Args:
            event (wx.CloseEvent, optional): The event that triggered this
                function.
        """
        if getattr(self.worker, "thread", None) and self.worker.thread.is_alive():
            self.worker.abort_subprocess(True)
            return None
        if (not event or self.IsShownOnScreen()) and self.tc_check_save_ti1(False):
            setcfg("tc.saturation_sweeps", self.saturation_sweeps_intctrl.GetValue())
            for component in ("R", "G", "B"):
                setcfg(
                    f"tc.saturation_sweeps.custom.{component}",
                    getattr(
                        self, f"saturation_sweeps_custom_{component}_ctrl"
                    ).GetValue(),
                )
            self.worker.wrapup(False)
            # Hide first (looks nicer)
            self.Hide()
            if self.Parent:
                setcfg("tc.show", 0)
                return True
            self.writecfg()
            # Need to use CallAfter to prevent hang under Windows if minimized
            wx.CallAfter(self.Destroy)
        elif isinstance(event, wx.CloseEvent) and event.CanVeto():
            event.Veto()
        return None

    def tc_move_handler(self, event=None):
        """Handle the movement of the testchart editor window.

        Args:
            event (wx.MoveEvent, optional): The event that triggered this function.
        """
        if self.IsShownOnScreen() and not self.IsMaximized() and not self.IsIconized():
            x, y = self.GetScreenPosition()
            setcfg("position.tcgen.x", x)
            setcfg("position.tcgen.y", y)
        if event:
            event.Skip()

    def tc_destroy_handler(self, event):
        """Handle the destruction of the testchart editor.

        Args:
            event: The event that triggered this function.
        """
        event.Skip()

    def tc_load_cfg_from_ti1(
        self,
        event=None,
        path=None,
        cfg=None,
        parent_set_chart_methodname=None,
        resume=False,
    ):
        """Load a testchart from a file and update the UI accordingly.

        Args:
            event: The event that triggered this function (optional).
            path (str): Path to the testchart file (TI1, ICC, ICM, or TI3).
            cfg (str): Configuration key to set the loaded testchart.
            parent_set_chart_methodname (str): Method name in the parent to set
                the chart.
            resume (bool): Whether to resume a previous operation.
        """
        if self.worker.is_working():
            return

        if cfg:
            self.cfg = cfg
        if path is None:
            path = getcfg(self.cfg)
        if path == "auto":
            return
        if parent_set_chart_methodname:
            self.parent_set_chart_methodname = parent_set_chart_methodname

        if not self.tc_check_save_ti1():
            return

        print(lang.getstr("testchart.read"))
        self.worker.interactive = False
        self.worker.start(
            self.tc_load_cfg_from_ti1_finish,
            self.tc_load_cfg_from_ti1_worker,
            wargs=(path,),
            wkwargs={},
            progress_title=lang.getstr("testchart.read"),
            progress_msg=lang.getstr("testchart.read"),
            parent=self,
            progress_start=500,
            cancelable=False,
            resume=resume,
            show_remaining_time=False,
            fancy=False,
        )

    def tc_load_cfg_from_ti1_worker(self, path):
        """Worker function to load a testchart from a file.

        Args:
            path (str): Path to the testchart file (TI1, ICC, ICM, or TI3).

        Returns:
            CGATS: The loaded testchart as a CGATS object.
        """
        path = str(path)
        try:
            filename, ext = os.path.splitext(path)
            if ext.lower() not in (".icc", ".icm"):
                if ext.lower() == ".ti3":
                    with open(path, "rb") as f:
                        ti3_data = f.read()
                    ti1 = CGATS(ti3_to_ti1(ti3_data))
                    ti1.filename = filename + ".ti1"
                else:
                    ti1 = CGATS(path)
                    ti1.filename = path
            else:  # icc or icm profile
                profile = ICCProfile(path)
                ti1 = CGATS(
                    ti3_to_ti1(
                        profile.tags.get("CIED", "") or profile.tags.get("targ", "")
                    )
                )
                ti1.filename = filename + ".ti1"
            ti1.fix_device_values_scaling()
            try:
                ti1_1 = verify_cgats(ti1, ("RGB_R", "RGB_B", "RGB_G"))
            except CGATSError as exception:
                msg = {
                    CGATSKeyError: lang.getstr(
                        "error.testchart.missing_fields", (path, "RGB_R, RGB_G, RGB_B")
                    )
                }.get(
                    exception.__class__,
                    lang.getstr("error.testchart.invalid", path)
                    + "\n"
                    + lang.getstr(str(exception)),
                )
                return Error(msg)
            else:
                try:
                    verify_cgats(ti1, ("XYZ_X", "XYZ_Y", "XYZ_Z"))
                except CGATSKeyError:
                    # Missing XYZ, add via simple sRGB-like model
                    data = ti1_1.queryv1("DATA")
                    data.parent.DATA_FORMAT.add_data(("XYZ_X", "XYZ_Y", "XYZ_Z"))
                    for sample in data.values():
                        XYZ = argyll_rgb2xyz.rgb2xyz(
                            *[sample["RGB_" + channel] / 100.0 for channel in "RGB"]
                        )
                        for i, component in enumerate("XYZ"):
                            sample["XYZ_" + component] = XYZ[i] * 100
                else:
                    if ext.lower() not in (".ti1", ".ti2") and ti1_1:
                        ti1_1.add_keyword("ACCURATE_EXPECTED_VALUES", "true")
                ti1.root.setmodified(False)
                self.ti1 = ti1
        except Exception as exception:
            return Error(
                "{}\n\n{}".format(
                    lang.getstr("error.testchart.read", path), str(exception)
                )
            )

        white_patches = self.ti1.queryv1("WHITE_COLOR_PATCHES") or None
        black_patches = self.ti1.queryv1("BLACK_COLOR_PATCHES") or None
        single_channel_patches = self.ti1.queryv1("SINGLE_DIM_STEPS") or None
        gray_patches = self.ti1.queryv1("COMP_GREY_STEPS") or None
        multi_bcc_steps = self.ti1.queryv1("MULTI_DIM_BCC_STEPS") or 0
        multi_steps = self.ti1.queryv1("MULTI_DIM_STEPS") or multi_bcc_steps
        fullspread_patches = self.ti1.queryv1("NUMBER_OF_SETS")
        gamma = self.ti1.queryv1("EXTRA_DEV_POW") or 1.0
        dark_emphasis = ((self.ti1.queryv1("DARK_REGION_EMPHASIS") or 1.0) - 1.0) / 3.0

        if None in (white_patches, single_channel_patches, gray_patches, multi_steps):
            if None in (single_channel_patches, gray_patches, multi_steps):
                white_patches = 0
                black_patches = 0
                R = []
                G = []
                B = []
                gray_channel = [0]
                data = self.ti1.queryv1("DATA")
                multi = {"R": [], "G": [], "B": []}
                if multi_steps is None:
                    multi_steps = 0
                uniqueRGB = []
                vmaxlen = 4
                for i in data:
                    if self.worker.thread_abort:
                        return False
                    # XXX Note that round(50 * 2.55) = 127, but
                    # round(50 / 100 * 255) = 128 (the latter is what we want)!
                    patch = [
                        round(v / 100.0 * 255, vmaxlen)
                        for v in (data[i]["RGB_R"], data[i]["RGB_G"], data[i]["RGB_B"])
                    ]  # normalize to 0...255 range
                    strpatch = [str(round(round(v, 1))) for v in patch]
                    if patch[0] == patch[1] == patch[2] == 255:  # white
                        white_patches += 1
                        if 255 not in gray_channel:
                            gray_channel.append(255)
                    elif patch[0] == patch[1] == patch[2] == 0:  # black
                        black_patches += 1
                        if 0 not in R and 0 not in G and 0 not in B:
                            R.append(0)
                            G.append(0)
                            B.append(0)
                        if 0 not in gray_channel:
                            gray_channel.append(0)
                    elif patch[2] == patch[1] == 0 and patch[0] not in R:  # red
                        R.append(patch[0])
                    elif patch[0] == patch[2] == 0 and patch[1] not in G:  # green
                        G.append(patch[1])
                    elif patch[0] == patch[1] == 0 and patch[2] not in B:  # blue
                        B.append(patch[2])
                    elif patch[0] == patch[1] == patch[2]:  # gray
                        if patch[0] not in gray_channel:
                            gray_channel.append(patch[0])
                    elif multi_steps == 0:
                        multi_steps = None
                    if DEBUG >= 9:
                        print("[D]", strpatch)
                    if strpatch not in uniqueRGB:
                        uniqueRGB.append(strpatch)
                        if patch[0] not in multi["R"]:
                            multi["R"].append(patch[0])
                        if patch[1] not in multi["G"]:
                            multi["G"].append(patch[1])
                        if patch[2] not in multi["B"]:
                            multi["B"].append(patch[2])

                if single_channel_patches is None:
                    single_channel_patches = min(len(R), len(G), len(B))
                if gray_patches is None:
                    gray_patches = len(gray_channel)
                if multi_steps is None:
                    multi_steps = 0

                if single_channel_patches is None:
                    # NEVER (old code, needs work for demphasis/gamma, remove?)
                    R_inc = self.tc_get_increments(R, vmaxlen)
                    G_inc = self.tc_get_increments(G, vmaxlen)
                    B_inc = self.tc_get_increments(B, vmaxlen)
                    if DEBUG:
                        print("[D] R_inc:")
                        for i in R_inc:
                            if self.worker.thread_abort:
                                return False
                            print(f"[D] {i}: x{R_inc[i]}")
                        print("[D] G_inc:")
                        for i in G_inc:
                            if self.worker.thread_abort:
                                return False
                            print(f"[D] {i}: x{G_inc[i]}")
                        print("[D] B_inc:")
                        for i in B_inc:
                            if self.worker.thread_abort:
                                return False
                            print(f"[D] {i}: x{B_inc[i]}")
                    RGB_inc = {"0": 0}
                    for inc in R_inc:
                        if self.worker.thread_abort:
                            return False
                        if (
                            inc in G_inc
                            and inc in B_inc
                            and R_inc[inc] == G_inc[inc] == B_inc[inc]
                        ):
                            RGB_inc[inc] = R_inc[inc]
                    for inc in G_inc:
                        if self.worker.thread_abort:
                            return False
                        if (
                            inc in R_inc
                            and inc in B_inc
                            and R_inc[inc] == G_inc[inc] == B_inc[inc]
                        ):
                            RGB_inc[inc] = G_inc[inc]
                    for inc in B_inc:
                        if self.worker.thread_abort:
                            return False
                        if (
                            inc in R_inc
                            and inc in G_inc
                            and R_inc[inc] == G_inc[inc] == B_inc[inc]
                        ):
                            RGB_inc[inc] = B_inc[inc]
                    if False:
                        RGB_inc_max = max(RGB_inc.values())
                        if RGB_inc_max > 0:
                            single_channel_patches = RGB_inc_max + 1
                        else:
                            single_channel_patches = 0
                    else:
                        single_inc = {"0": 0}
                        for inc in RGB_inc:
                            if self.worker.thread_abort:
                                return False
                            if inc == "0":
                                continue
                            finc = float(inc)
                            n = round(float(str(255.0 / finc)))
                            finc = 255.0 / n
                            n += 1
                            if DEBUG >= 9:
                                print("[D] inc:", inc)
                                print("[D] n:", n)
                            for i in range(n):
                                if self.worker.thread_abort:
                                    return False
                                v = str(round(float(str(i * finc))))
                                if DEBUG >= 9:
                                    print("[D] Searching for", v)
                                if (
                                    [v, "0", "0"] in uniqueRGB
                                    and ["0", v, "0"] in uniqueRGB
                                    and ["0", "0", v] in uniqueRGB
                                ):
                                    if inc not in single_inc:
                                        single_inc[inc] = 0
                                    single_inc[inc] += 1
                                else:
                                    if DEBUG >= 9:
                                        print("[D] Not found!")
                                    break
                        single_channel_patches = max(single_inc.values())
                    if DEBUG:
                        print("[D] single_channel_patches:", single_channel_patches)
                    if 0 in R + G + B:
                        fullspread_patches += 3  # black in single channel patches
                elif single_channel_patches >= 2:
                    fullspread_patches += 3  # black always in SINGLE_DIM_STEPS

                if gray_patches is None:
                    # NEVER (old code, needs work for demphasis/gamma, remove?)
                    RGB_inc = self.tc_get_increments(gray_channel, vmaxlen)
                    if DEBUG:
                        print("[D] RGB_inc:")
                        for i in RGB_inc:
                            if self.worker.thread_abort:
                                return False
                            print(f"[D] {i}: x{RGB_inc[i]}")
                    if False:
                        RGB_inc_max = max(RGB_inc.values())
                        gray_patches = RGB_inc_max + 1 if RGB_inc_max > 0 else 0
                    else:
                        gray_inc = {"0": 0}
                        for inc in RGB_inc:
                            if self.worker.thread_abort:
                                return False
                            if inc == "0":
                                continue
                            finc = float(inc)
                            n = round(float(str(255.0 / finc)))
                            finc = 255.0 / n
                            n += 1
                            if DEBUG >= 9:
                                print("[D] inc:", inc)
                                print("[D] n:", n)
                            for i in range(n):
                                if self.worker.thread_abort:
                                    return False
                                v = str(round(float(str(i * finc))))
                                if DEBUG >= 9:
                                    print("[D] Searching for", v)
                                if [v, v, v] in uniqueRGB:
                                    if inc not in gray_inc:
                                        gray_inc[inc] = 0
                                    gray_inc[inc] += 1
                                else:
                                    if DEBUG >= 9:
                                        print("[D] Not found!")
                                    break
                        gray_patches = max(gray_inc.values())
                    if DEBUG:
                        print("[D] gray_patches:", gray_patches)
                    if 0 in gray_channel:
                        fullspread_patches += 1  # black in gray patches
                    if 255 in gray_channel:
                        fullspread_patches += 1  # white in gray patches
                elif gray_patches >= 2:
                    fullspread_patches += 2  # black and white always in COMP_GREY_STEPS

                if multi_steps is None:
                    # NEVER (old code, needs work for demphasis/gamma, remove?)
                    R_inc = self.tc_get_increments(multi["R"], vmaxlen)
                    G_inc = self.tc_get_increments(multi["G"], vmaxlen)
                    B_inc = self.tc_get_increments(multi["B"], vmaxlen)
                    RGB_inc = {"0": 0}
                    for inc in R_inc:
                        if self.worker.thread_abort:
                            return False
                        if (
                            inc in G_inc
                            and inc in B_inc
                            and R_inc[inc] == G_inc[inc] == B_inc[inc]
                        ):
                            RGB_inc[inc] = R_inc[inc]
                    for inc in G_inc:
                        if self.worker.thread_abort:
                            return False
                        if (
                            inc in R_inc
                            and inc in B_inc
                            and R_inc[inc] == G_inc[inc] == B_inc[inc]
                        ):
                            RGB_inc[inc] = G_inc[inc]
                    for inc in B_inc:
                        if self.worker.thread_abort:
                            return False
                        if (
                            inc in R_inc
                            and inc in G_inc
                            and R_inc[inc] == G_inc[inc] == B_inc[inc]
                        ):
                            RGB_inc[inc] = B_inc[inc]
                    if DEBUG:
                        print("[D] RGB_inc:")
                        for i in RGB_inc:
                            if self.worker.thread_abort:
                                return False
                            print(f"[D] {i}: x{RGB_inc[i]}")
                    multi_inc = {"0": 0}
                    for inc in RGB_inc:
                        if self.worker.thread_abort:
                            return False
                        if inc == "0":
                            continue
                        finc = float(inc)
                        n = round(float(str(255.0 / finc)))
                        finc = 255.0 / n
                        n += 1
                        if DEBUG >= 9:
                            print("[D] inc:", inc)
                            print("[D] n:", n)
                        for i in range(n):
                            if self.worker.thread_abort:
                                return False
                            r = str(round(float(str(i * finc))))
                            for j in range(n):
                                if self.worker.thread_abort:
                                    return False
                                g = str(round(float(str(j * finc))))
                                for k in range(n):
                                    if self.worker.thread_abort:
                                        return False
                                    b = str(round(float(str(k * finc))))
                                    if DEBUG >= 9:
                                        print("[D] Searching for", i, j, k, [r, g, b])
                                    if [r, g, b] in uniqueRGB:
                                        if inc not in multi_inc:
                                            multi_inc[inc] = 0
                                        multi_inc[inc] += 1
                                    else:
                                        if DEBUG >= 9:
                                            print("[D] Not found! (b loop)")
                                        break
                                if [r, g, b] not in uniqueRGB:
                                    if DEBUG >= 9:
                                        print("[D] Not found! (g loop)")
                                    break
                            if [r, g, b] not in uniqueRGB:
                                if DEBUG >= 9:
                                    print("[D] Not found! (r loop)")
                                break
                    multi_patches = max(multi_inc.values())
                    multi_steps = int(float(str(math.pow(multi_patches, 1 / 3.0))))
                    if DEBUG:
                        print("[D] multi_patches:", multi_patches)
                        print("[D] multi_steps:", multi_steps)
                elif multi_steps >= 2:
                    fullspread_patches += 2  # black and white always in MULTI_DIM_STEPS
            else:
                white_patches = len(
                    self.ti1[0].queryi({"RGB_R": 100, "RGB_G": 100, "RGB_B": 100})
                )
                black_patches = len(
                    self.ti1[0].queryi({"RGB_R": 0, "RGB_G": 0, "RGB_B": 0})
                )
                if single_channel_patches >= 2:
                    fullspread_patches += 3  # black always in SINGLE_DIM_STEPS
                if gray_patches >= 2:
                    fullspread_patches += 2  # black and white always in COMP_GREY_STEPS
                if multi_steps >= 2:
                    fullspread_patches += 2  # black and white always in MULTI_DIM_STEPS
            fullspread_patches -= white_patches
            if self.worker.argyll_version >= [1, 6]:
                fullspread_patches -= black_patches
            fullspread_patches -= single_channel_patches * 3
            fullspread_patches -= gray_patches
            fullspread_patches -= (
                int(float(str(math.pow(multi_steps, 3)))) - single_channel_patches * 3
            )

        return (
            white_patches,
            black_patches,
            single_channel_patches,
            gray_patches,
            multi_steps,
            multi_bcc_steps,
            fullspread_patches,
            gamma,
            dark_emphasis,
        )

    def tc_load_cfg_from_ti1_finish(self, result):
        """Finish loading testchart configuration from ti1 file.

        Args:
            result (tuple or Exception): The result of the worker thread.
        """
        self.worker.wrapup(False)
        if isinstance(result, tuple):
            # UGLY HACK: This 'print' call fixes a GTK assertion and
            # segfault under Arch Linux when setting the window title
            print("")
            self.SetTitle(
                lang.getstr("testchart.edit").rstrip(".")
                + ": "
                + os.path.basename(self.ti1.filename)
            )

            print(lang.getstr("success"))
            (
                white_patches,
                black_patches,
                single_channel_patches,
                gray_patches,
                multi_steps,
                multi_bcc_steps,
                fullspread_patches,
                gamma,
                dark_emphasis,
            ) = result

            fullspread_ba = {
                "ERROR_OPTIMISED_PATCHES": "",  # OFPS in older Argyll CMS versions
                # "ERROR_OPTIMISED_PATCHES": "R",  # Perc. space random
                #                                    - same keyword as OFPS in older
                #                                      Argyll CMS versions, don't use.
                "IFP_PATCHES": "t",  # Inc. far point
                "INC_FAR_PATCHES": "t",  # Inc. far point in older Argyll CMS versions
                "OFPS_PATCHES": "",  # OFPS
                "RANDOM_DEVICE_PATCHES": "r",  # Dev. space random
                "RANDOM_PATCHES": "r",  # Dev. space random in older Argyll CMS versions
                "RANDOM_PERCEPTUAL_PATCHES": "R",  # Perc. space random
                # "RANDOM_PERCEPTUAL_PATCHES": "Q",  # Perc. space filling quasi-random
                #                                      - same keyword as perc.
                #                                        space random, don't use
                "SIMPLEX_DEVICE_PATCHES": "i",  # Dev. space body centered cubic grid
                "SIMPLEX_PERCEPTUAL_PATCHES": "I",  # Perc. space body centered cubic
                #                                     grid
                "SPACEFILING_RANDOM_PATCHES": "q",  # Device space filling quasi-random,
                #                                     typo in older Argyll CMS versions
                "SPACEFILLING_RANDOM_PATCHES": "q",  # Device space filling quasi-random
            }

            algo = None

            for key in list(fullspread_ba.keys()):
                queryv_ = self.ti1.queryv1(key)
                if queryv_ is not None and queryv_ > 0:
                    algo = fullspread_ba[key]
                    break

            if white_patches is not None:
                setcfg("tc_white_patches", white_patches)
            if black_patches is not None:
                setcfg("tc_black_patches", black_patches)
            if single_channel_patches is not None:
                setcfg("tc_single_channel_patches", single_channel_patches)
            if gray_patches is not None:
                setcfg("tc_gray_patches", gray_patches)
            if multi_steps is not None:
                setcfg("tc_multi_steps", multi_steps)
            if multi_bcc_steps is not None:
                setcfg("tc_multi_bcc_steps", multi_bcc_steps)
            setcfg(
                "tc_fullspread_patches",
                self.ti1.queryv1("NUMBER_OF_SETS")
                - self.tc_get_total_patches(
                    white_patches,
                    black_patches,
                    single_channel_patches,
                    gray_patches,
                    multi_steps,
                    multi_bcc_steps,
                    0,
                ),
            )
            if gamma is not None:
                setcfg("tc_gamma", gamma)
            if dark_emphasis is not None:
                setcfg("tc_dark_emphasis", dark_emphasis)
            if algo is not None:
                setcfg("tc_algo", algo)
            self.writecfg()

            self.tc_update_controls()
            self.tc_preview(True)
            return True
        print(lang.getstr("aborted"))
        if self.Parent and hasattr(self.Parent, "start_timers"):
            self.Parent.start_timers()
        if isinstance(result, Exception):
            show_result_dialog(result, self)
        return False

    def tc_get_increments(self, channel, vmaxlen=4):
        """Get the increments of a channel.

        Args:
            channel (list): The channel.
            vmaxlen (int): The maximum length of the increment string.

        Returns:
            dict: A dictionary with increments as keys and their counts as
                values.
        """
        channel.sort()
        increments = {"0": 0}
        for i, v in enumerate(channel):
            for j in reversed(range(i, len(channel))):
                inc = round(float(str(channel[j] - v)), vmaxlen)
                if inc > 0:
                    inc = str(inc)
                    if inc not in increments:
                        increments[inc] = 0
                    increments[inc] += 1
        return increments

    def tc_create(self, gray=False, multidim=False, single=False):
        """Create testchart using targen.

        Setting gray, multidim or single to True will ad those patches
        in a separate step if any number of iterative patches are to be
        generated as well.

        Args:
            gray (bool): Whether to include gray patches.
            multidim (bool): Whether to include multi-dimensional patches.
            single (bool): Whether to include single-channel patches.
        """
        self.writecfg()
        fullspread_patches = getcfg("tc_fullspread_patches")
        white_patches = getcfg("tc_white_patches")
        black_patches = getcfg("tc_black_patches")
        single_patches = getcfg("tc_single_channel_patches")
        gray_patches = getcfg("tc_gray_patches")
        multidim_patches = getcfg("tc_multi_steps")
        multidim_bcc_patches = getcfg("tc_multi_bcc_steps")
        extra_args = getcfg("extra_args.targen")
        result = True
        fixed_ti1 = None
        if fullspread_patches > 0 and (gray or multidim or single):
            # Generate fixed points first so they don't punch "holes" into the
            # OFPS distribution
            setcfg("tc_white_patches", 0)
            setcfg("tc_black_patches", 0)
            if not single:
                setcfg("tc_single_channel_patches", 0)
            if not gray:
                setcfg("tc_gray_patches", 0)
            if not multidim:
                setcfg("tc_multi_steps", 0)
                setcfg("tc_multi_bcc_steps", 0)
            setcfg("tc_fullspread_patches", 0)
            setcfg("extra_args.targen", "")
            result = self.tc_create_ti1()
        if fullspread_patches > 0 and (gray or multidim or single):
            setcfg("tc_white_patches", white_patches)
            setcfg("tc_black_patches", black_patches)
            if single:
                setcfg("tc_single_channel_patches", 2)
            else:
                setcfg("tc_single_channel_patches", single_patches)
            if gray:
                setcfg("tc_gray_patches", 2)
            else:
                setcfg("tc_gray_patches", gray_patches)
            if multidim:
                setcfg("tc_multi_steps", 2)
                setcfg("tc_multi_bcc_steps", 0)
            else:
                setcfg("tc_multi_steps", multidim_patches)
                setcfg("tc_multi_bcc_steps", multidim_bcc_patches)
            setcfg("tc_fullspread_patches", fullspread_patches)
            setcfg("extra_args.targen", extra_args)
            fixed_ti1 = result
        if not isinstance(result, Exception) and result:
            result = self.tc_create_ti1()
            if isinstance(result, CGATS):
                if fixed_ti1:
                    if gray:
                        result[0].add_keyword("COMP_GREY_STEPS", gray_patches)
                    if multidim:
                        result[0].add_keyword("MULTI_DIM_STEPS", multidim_patches)
                        if multidim_bcc_patches:
                            result[0].add_keyword(
                                "MULTI_DIM_BCC_STEPS", multidim_bcc_patches
                            )
                    if single:
                        result[0].add_keyword("SINGLE_DIM_STEPS", single_patches)
                    fixed_data = fixed_ti1.queryv1("DATA")
                    data = result.queryv1("DATA")
                    data_format = result.queryv1("DATA_FORMAT")
                    # Get only RGB data
                    data.parent.DATA_FORMAT = CGATS()
                    data.parent.DATA_FORMAT.key = "DATA_FORMAT"
                    data.parent.DATA_FORMAT.parent = data
                    data.parent.DATA_FORMAT.root = data.root
                    data.parent.DATA_FORMAT.type = "DATA_FORMAT"
                    for i, label in enumerate(("RGB_R", "RGB_G", "RGB_B")):
                        data.parent.DATA_FORMAT[i] = label
                    fixed_data.parent.DATA_FORMAT = data.parent.DATA_FORMAT
                    rgbdata = str(data)
                    # Restore DATA_FORMAT
                    data.parent.DATA_FORMAT = data_format
                    # Collect all fixed point datasets not in data
                    fixed_data.vmaxlen = data.vmaxlen
                    fixed_datasets = []
                    for i in fixed_data:
                        dataset = fixed_data[i]
                        if str(dataset) not in rgbdata:
                            fixed_datasets.append(dataset)
                    if fixed_datasets:
                        # Insert fixed point datasets after first patch
                        data.moveby1(1, len(fixed_datasets))
                        for i, dataset in enumerate(fixed_datasets):
                            dataset.key = i + 1
                            dataset.parent = data
                            dataset.root = data.root
                            data[dataset.key] = dataset
                self.ti1 = result
        setcfg("tc_single_channel_patches", single_patches)
        setcfg("tc_gray_patches", gray_patches)
        setcfg("tc_multi_steps", multidim_patches)
        setcfg("tc_multi_bcc_steps", multidim_bcc_patches)
        return result

    def tc_create_ti1(self):
        """Create a testchart using targen.

        Returns:
            CGATS or Exception: The created testchart or an error.
        """
        cmd, args = self.worker.prepare_targen()
        if not isinstance(cmd, Exception):
            result = self.worker.exec_cmd(
                cmd,
                args,
                low_contrast=False,
                skip_scripts=True,
                silent=False,
                parent=self,
            )
        else:
            result = cmd
        if (
            not isinstance(result, Exception)
            and result
            and not isinstance(result, Exception)
        ):
            path = os.path.join(self.worker.tempdir, "temp.ti1")
            result = check_file_isfile(path, silent=False)
            if not isinstance(result, Exception) and result:
                try:
                    result = CGATS(path)
                    print(lang.getstr("success"))
                except Exception as exception:
                    result = Error(
                        f"Error - testchart file could not be read: {exception!s} "
                    )
                else:
                    result.filename = None
        self.worker.wrapup(False)
        return result

    def tc_preview(self, result, path=None):
        """Create a preview of the testchart.

        Args:
            result (CGATS or Exception): The result of the testchart creation.
            path (str, optional): The path to save the testchart. Defaults to None.
        """
        self.tc_check()
        if isinstance(result, Exception):
            show_result_dialog(result, self)
        elif result:
            if not hasattr(self, "separator"):
                # We add this here because of a wxGTK 2.8 quirk where the
                # vertical scrollbar otherwise has a 1px horizontal
                # line at the top otherwise.
                separator_color = wx.SystemSettings.GetColour(wx.SYS_COLOUR_3DSHADOW)
                self.separator = wx.Panel(self.panel, size=(-1, 1))
                self.separator.BackgroundColour = separator_color
                index = len(self.sizer.Children) - 1
                if sys.platform not in ("darwin", "win32") or TC_USE_ALTERNATE_PREVIEW:
                    index -= 1
                self.sizer.Insert(index, self.separator, flag=wx.EXPAND)
            else:
                self.separator.Show()
            self.sizer.Layout()
            if VERBOSE >= 1:
                print(lang.getstr("tc.preview.create"))
            data = self.ti1.queryv1("DATA")
            vmaxlen = 6

            if hasattr(self, "preview"):
                self.preview.BeginBatch()
                w = self.grid.GetDefaultRowSize()
                numcols = (
                    self.sizer.Size[0] - wx.SystemSettings_GetMetric(wx.SYS_VSCROLL_X)
                ) / w
                self.preview.AppendCols(numcols)
                for i in range(numcols):
                    self.preview.SetColLabelValue(i, str(i + 1))

            grid = self.grid
            grid.BeginBatch()
            data_format = self.ti1.queryv1("DATA_FORMAT")
            for i in data_format:
                data_format_str_value = data_format[i].decode("utf-8")
                if data_format_str_value in ("RGB_R", "RGB_G", "RGB_B"):
                    grid.AppendCols(1)
                    grid.SetColLabelValue(
                        grid.GetNumberCols() - 1,
                        data_format_str_value.split("_")[-1] + " %",
                    )
            grid.AppendCols(1)
            grid.SetColLabelValue(grid.GetNumberCols() - 1, "")
            grid.SetColSize(grid.GetNumberCols() - 1, self.grid.GetDefaultRowSize())
            self.tc_amount = self.ti1.queryv1("NUMBER_OF_SETS")
            grid.AppendRows(self.tc_amount)
            dc = wx.MemoryDC(wx.EmptyBitmap(1, 1))
            dc.SetFont(grid.GetLabelFont())
            w, h = dc.GetTextExtent("99{}".format(self.ti1.queryv1("NUMBER_OF_SETS")))
            grid.SetRowLabelSize(max(w, grid.GetDefaultRowSize()))
            attr = wx.grid.GridCellAttr()
            attr.SetAlignment(wx.ALIGN_CENTER, wx.ALIGN_CENTER)
            attr.SetReadOnly()
            grid.SetColAttr(grid.GetNumberCols() - 1, attr)

            for i in data:
                sample = data[i]
                for j in range(grid.GetNumberCols()):
                    label = self.label_b2a.get(grid.GetColLabelValue(j))
                    if label in ("RGB_R", "RGB_G", "RGB_B"):
                        grid.SetCellValue(
                            i,
                            j,
                            rpad(
                                sample[label], vmaxlen + (1 if sample[label] < 0 else 0)
                            ),
                        )
                self.tc_grid_setcolorlabel(i, data)
            self.tc_preview_update(0)

            if hasattr(self, "preview"):
                w, h = dc.GetTextExtent(f"99{self.preview.GetNumberRows()}")
                self.preview.SetRowLabelSize(max(w, grid.GetDefaultRowSize()))
                self.preview.EndBatch()

            self.tc_set_default_status()
            if VERBOSE >= 1:
                print(lang.getstr("success"))
            self.resize_grid()
            grid.EndBatch()
            if path:
                wx.CallAfter(self.tc_save_as_handler, path=path)
        if self.Parent and hasattr(self.Parent, "start_timers"):
            self.Parent.start_timers()

    def tc_add_data(self, row, newdata):
        """Add new data to the grid and update the preview.

        Args:
            row (int): The row number where new data should be inserted.
            newdata (list): A list of dictionaries containing the new data.
        """
        self.grid.BeginBatch()
        self.grid.InsertRows(row + 1, len(newdata))
        data = self.ti1.queryv1("DATA")
        if hasattr(self, "preview"):
            self.preview.BeginBatch()
        data_format = self.ti1.queryv1("DATA_FORMAT")
        data.moveby1(row + 1, len(newdata))
        for i in range(len(newdata)):
            dataset = CGATS()
            for label in data_format.values():
                label = label.decode("utf-8")
                if label not in newdata[i]:
                    newdata[i][label] = 0.0
                dataset[label] = newdata[i][label]
            dataset.key = row + 1 + i
            dataset.parent = data
            dataset.root = data.root
            dataset.type = b"SAMPLE"
            data[dataset.key] = dataset
            for label in ("RGB_R", "RGB_G", "RGB_B"):
                for col in range(self.grid.GetNumberCols()):
                    if self.label_b2a.get(self.grid.GetColLabelValue(col)) == label:
                        sample = newdata[i]
                        self.grid.SetCellValue(
                            row + 1 + i,
                            col,
                            rpad(
                                sample[label],
                                data.vmaxlen + (1 if sample[label] < 0 else 0),
                            ),
                        )
            self.tc_grid_setcolorlabel(row + 1 + i, data)
        self.tc_preview_update(row + 1)
        self.tc_amount = self.ti1.queryv1("NUMBER_OF_SETS")
        dc = wx.MemoryDC(wx.EmptyBitmap(1, 1))
        dc.SetFont(self.grid.GetLabelFont())
        w, h = dc.GetTextExtent(f"99{self.tc_amount}")
        self.grid.SetRowLabelSize(max(w, self.grid.GetDefaultRowSize()))
        self.resize_grid()
        self.grid.EndBatch()
        self.tc_set_default_status()
        self.tc_save_check()
        if hasattr(self, "preview"):
            self.preview.EndBatch()

    def tc_grid_setcolorlabel(self, row, data=None):
        """Set the color label and background for a grid cell.

        Args:
            row (int): The row number of the grid to update.
            data (CGATS, optional): The CGATS data object containing sample data.
        """
        grid = self.grid
        col = grid.GetNumberCols() - 1
        if data is None:
            data = self.ti1.queryv1("DATA")
        sample = data[row]
        style, colour, labeltext, labelcolour = self.tc_getcolorlabel(sample)
        grid.SetCellBackgroundColour(row, col, colour)
        grid.SetCellValue(row, col, labeltext)
        if labelcolour:
            grid.SetCellTextColour(row, col, labelcolour)
        self.grid.Refresh()
        if hasattr(self, "preview"):
            style, colour, labeltext, labelcolour = self.tc_getcolorlabel(sample)
            numcols = self.preview.GetNumberCols()
            row = sample.key / numcols
            col = sample.key % numcols
            if row > self.preview.GetNumberRows() - 1:
                self.preview.AppendRows(1)
            self.preview.SetCellBackgroundColour(row, col, colour)
            self.preview.SetCellValue(row, col, labeltext)
            if labelcolour:
                self.preview.SetCellTextColour(row, col, labelcolour)
            self.preview.Refresh()

    def tc_getcolorlabel(self, sample):
        """Get the style, colour, label text and label colour for a sample.

        Args:
            sample (CGATS): The sample data containing RGB values.
        """
        colour = wx.Colour(
            *[
                round(value / 100.0 * 255)
                for value in (sample.RGB_R, sample.RGB_G, sample.RGB_B)
            ]
        )
        # mark patches:
        # W = white (R/G/B == 100)
        # K = black (R/G/B == 0)
        # k = light black (R == G == B > 0)
        # R = red
        # r = light red (R == 100 and G/B > 0)
        # G = green
        # g = light green (G == 100 and R/B > 0)
        # B = blue
        # b = light blue (B == 100 and R/G > 0)
        # C = cyan
        # c = light cyan (G/B == 100 and R > 0)
        # M = magenta
        # m = light magenta (R/B == 100 and G > 0)
        # Y = yellow
        # y = light yellow (R/G == 100 and B > 0)
        # border = 50% value
        style = wx.NO_BORDER
        if sample.RGB_R == sample.RGB_G == sample.RGB_B:  # Neutral / black / white
            if sample.RGB_R < 50:
                labelcolour = wx.Colour(255, 255, 255)
            else:
                if sample.RGB_R == 50:
                    style = wx.SIMPLE_BORDER
                labelcolour = wx.Colour(0, 0, 0)
            if sample.RGB_R <= 50:
                labeltext = "K"
            elif sample.RGB_R == 100:
                labeltext = "W"
            else:
                labeltext = "k"
        elif (sample.RGB_G == 0 and sample.RGB_B == 0) or (
            sample.RGB_R == 100 and sample.RGB_G == sample.RGB_B
        ):  # Red
            if sample.RGB_R > 75:
                labelcolour = wx.Colour(0, 0, 0)
            else:
                labelcolour = wx.Colour(255, 255, 255)
                if sample.RGB_R == 50:
                    style = wx.SIMPLE_BORDER
            labeltext = "r" if sample.RGB_R == 100 and sample.RGB_G > 0 else "R"
        elif (sample.RGB_R == 0 and sample.RGB_B == 0) or (
            sample.RGB_G == 100 and sample.RGB_R == sample.RGB_B
        ):  # Green
            if sample.RGB_G > 75:
                labelcolour = wx.Colour(0, 0, 0)
            else:
                labelcolour = wx.Colour(255, 255, 255)
                if sample.RGB_G == 50:
                    style = wx.SIMPLE_BORDER
            labeltext = "g" if sample.RGB_G == 100 and sample.RGB_R > 0 else "G"
        elif (sample.RGB_R == 0 and sample.RGB_G == 0) or (
            sample.RGB_B == 100 and sample.RGB_R == sample.RGB_G
        ):  # Blue
            if sample.RGB_R > 25:
                labelcolour = wx.Colour(0, 0, 0)
            else:
                labelcolour = wx.Colour(255, 255, 255)
                if sample.RGB_B == 50:
                    style = wx.SIMPLE_BORDER
            labeltext = "b" if sample.RGB_B == 100 and sample.RGB_R > 0 else "B"
        elif (
            sample.RGB_R == 0 or sample.RGB_B == 100
        ) and sample.RGB_G == sample.RGB_B:  # Cyan
            if sample.RGB_G > 75:
                labelcolour = wx.Colour(0, 0, 0)
            else:
                labelcolour = wx.Colour(255, 255, 255)
                if sample.RGB_G == 50:
                    style = wx.SIMPLE_BORDER
            labeltext = "c" if sample.RGB_G == 100 and sample.RGB_R > 0 else "C"
        elif (
            sample.RGB_G == 0 or sample.RGB_R == 100
        ) and sample.RGB_R == sample.RGB_B:  # Magenta
            if sample.RGB_R > 75:
                labelcolour = wx.Colour(0, 0, 0)
            else:
                labelcolour = wx.Colour(255, 255, 255)
                if sample.RGB_R == 50:
                    style = wx.SIMPLE_BORDER
            labeltext = "m" if sample.RGB_R == 100 and sample.RGB_G > 0 else "M"
        elif (
            sample.RGB_B == 0 or sample.RGB_G == 100
        ) and sample.RGB_R == sample.RGB_G:  # Yellow
            if sample.RGB_G > 75:
                labelcolour = wx.Colour(0, 0, 0)
            else:
                labelcolour = wx.Colour(255, 255, 255)
                if sample.RGB_R == 100 and sample.RGB_G == 50:
                    style = wx.SIMPLE_BORDER
            labeltext = "y" if sample.RGB_B > 0 else "Y"
        else:
            labeltext = ""
            labelcolour = None
        return style, colour, labeltext, labelcolour

    def tc_set_default_status(self, event=None):
        """Set the default status text for the testchart editor.

        Args:
            event (wx.Event, optional): The event that triggered this method.
        """
        if not hasattr(self, "tc_amount"):
            return

        statustxt = "{}: {}".format(lang.getstr("tc.patches.total"), self.tc_amount)
        sel = self.grid.GetSelectionRows()
        if sel:
            statustxt += " / {}: {}".format(
                lang.getstr("tc.patches.selected"),
                len(sel),
            )
            index = self.grid.GetGridCursorRow()
            if index > -1:
                colour = self.grid.GetCellBackgroundColour(index, 3)
                patchinfo = " \u2014 {} {}: R={} G={} B={}".format(
                    lang.getstr("tc.patch"),
                    index + 1,
                    colour[0],
                    colour[1],
                    colour[2],
                )
                statustxt += patchinfo
        self.SetStatusText(statustxt)

    def tc_mouseclick_handler(self, event):
        """Handle mouse click events on the testchart grid.

        This method selects the row in the grid based on the mouse click position.

        Args:
            event (wx.MouseEvent): The mouse event containing the click position.
        """
        if not getattr(self, "ti1", None):
            return
        index = event.Row * self.preview.GetNumberCols() + event.Col
        if index > self.ti1.queryv1("NUMBER_OF_SETS") - 1:
            return
        self.grid.select_row(
            index, event.ShiftDown(), event.ControlDown() or event.CmdDown()
        )
        return

    def tc_delete_rows(self, rows):
        """Delete rows from the testchart grid and update the data accordingly.

        Args:
            rows (list): List of row indices to delete.
        """
        self.grid.BeginBatch()
        if hasattr(self, "preview"):
            self.preview.BeginBatch()
        rows.sort()
        rows.reverse()
        data = self.ti1.queryv1("DATA")
        # Optimization: Delete consecutive rows in least number of operations
        consecutive = []
        rows.append(-1)
        for row in rows:
            if row == -1 or (consecutive and consecutive[-1] != row + 1):
                self.grid.DeleteRows(consecutive[-1], len(consecutive))
                if consecutive[0] != len(data) - 1:
                    data.moveby1(consecutive[-1] + len(consecutive), -len(consecutive))
                for _ in consecutive:
                    dict.pop(data, len(data) - 1)
                consecutive = []
            consecutive.append(row)
        rows.pop()
        if hasattr(self, "preview"):
            self.tc_preview_update(rows[-1])
        data.setmodified()
        self.tc_amount = self.ti1.queryv1("NUMBER_OF_SETS")
        row = min(rows[-1], self.grid.GetNumberRows() - 1)
        self.grid.SelectRow(row)
        self.grid.SetGridCursor(row, 0)
        self.grid.MakeCellVisible(row, 0)
        self.grid.EndBatch()
        self.tc_save_check()
        if hasattr(self, "preview"):
            self.preview.EndBatch()
        self.tc_set_default_status()

    def view_3d_format_popup(self, event):
        """Show a popup menu to select the 3D format for the testchart.

        Args:
            event (wx.MouseEvent): The event triggered by the right-click.
        """
        menu = wx.Menu()

        # item_selected = False
        for file_format in config.VALID_VALUES["3d.format"]:
            item = menu.AppendRadioItem(-1, file_format)
            item.Check(file_format == getcfg("3d.format"))
            self.Bind(wx.EVT_MENU, self.view_3d_format_handler, id=item.Id)

        self.PopupMenu(menu)
        for item in menu.MenuItems:
            self.Unbind(wx.EVT_MENU, id=item.Id)
        menu.Destroy()

    def view_3d_format_handler(self, event):
        """Handle the selection of a 3D format from the popup menu.

        Args:
            event (wx.CommandEvent): The event triggered by the menu selection.
        """
        for item in event.EventObject.MenuItems:
            if item.IsChecked():
                setcfg("3d.format", item.GetItemLabelText())

        self.tc_view_3d(None)

    def writecfg(self):
        """Write configuration settings to the config file."""
        if self.Parent:
            writecfg()
        else:
            writecfg(
                module="testchart-editor",
                options=(
                    "3d_format",
                    "last_ti1_path",
                    "last_testchart_export_path",
                    "last_vrml_path",
                    "position.tcgen",
                    "size.tcgen",
                    "tc.",
                    "tc_",
                ),
            )


def main():
    """Main function to run the wxPython application."""
    config.initcfg("testchart-editor")
    lang.init()
    lang.update_defaults()
    app = BaseApp(0)
    app.TopWindow = TestchartEditor(setup=False)
    if sys.platform == "darwin":
        app.TopWindow.init_menubar()
    wx.CallLater(1, _main, app)
    app.MainLoop()


def _main(app):
    app.TopWindow.listen()
    app.TopWindow.setup(path=False)
    app.process_argv(1) or app.TopWindow.tc_load_cfg_from_ti1()
    app.TopWindow.Show()


if __name__ == "__main__":
    main()
