"""Interactive display calibration UI."""

import os
import re
import sys
import time

from DisplayCAL import audio, colormath, config
from DisplayCAL import localization as lang
from DisplayCAL.config import (
    get_bitmap,
    get_data_path,
    get_icon,
    get_icon_bundle,
    getcfg,
    setcfg,
)
from DisplayCAL.log import get_file_logger
from DisplayCAL.meta import NAME as APPNAME
from DisplayCAL.options import DEBUG, TEST, VERBOSE
from DisplayCAL.wx_addons import wx
from DisplayCAL.wx_windows import (
    NAV_KEYCODES,
    NUMPAD_KEYCODES,
    PROCESSING_KEYCODES,
    BaseFrame,
    BitmapBackgroundPanel,
    CustomCheckBox,
    CustomGrid,
    FlatShadedButton,
    wx_Panel,
)

BGCOLOUR = wx.Colour(0x33, 0x33, 0x33)
FGCOLOUR = wx.Colour(0x99, 0x99, 0x99)


class UntetheredFrame(BaseFrame):
    """Untethered measurement frame."""

    def __init__(self, parent=None, handler=None, keyhandler=None, start_timer=True):
        BaseFrame.__init__(
            self,
            parent,
            wx.ID_ANY,
            lang.getstr("measurement.untethered"),
            style=wx.DEFAULT_FRAME_STYLE | wx.TAB_TRAVERSAL,
            name="untetheredframe",
        )
        self.SetIcons(get_icon_bundle([256, 48, 32, 16], APPNAME))
        self.sizer = wx.FlexGridSizer(2, 1, 0, 0)
        self.sizer.AddGrowableCol(0)
        self.sizer.AddGrowableRow(0)
        self.sizer.AddGrowableRow(1)
        self.panel = wx_Panel(self)
        self.SetSizer(self.sizer)
        self.sizer.Add(self.panel, 1, wx.EXPAND)
        self.panel.SetBackgroundColour(BGCOLOUR)
        panelsizer = wx.FlexGridSizer(3, 2, 8, 8)
        panelsizer.AddGrowableCol(0)
        panelsizer.AddGrowableCol(1)
        panelsizer.AddGrowableRow(1)
        self.panel.SetSizer(panelsizer)
        self.label_RGB = wx.StaticText(self.panel, wx.ID_ANY, " ")
        self.label_RGB.SetForegroundColour(FGCOLOUR)
        panelsizer.Add(self.label_RGB, 0, wx.TOP | wx.LEFT | wx.EXPAND, border=8)
        self.label_XYZ = wx.StaticText(self.panel, wx.ID_ANY, " ")
        self.label_XYZ.SetForegroundColour(FGCOLOUR)
        panelsizer.Add(self.label_XYZ, 0, wx.TOP | wx.RIGHT | wx.EXPAND, border=8)
        if sys.platform == "darwin":
            style = wx.BORDER_THEME
        else:
            style = wx.BORDER_SIMPLE
        self.panel_RGB = BitmapBackgroundPanel(self.panel, size=(256, 256), style=style)
        self.panel_RGB.scalebitmap = (True, True)
        self.panel_RGB.SetBitmap(get_bitmap("theme/checkerboard-32x32x5-333-444"))
        panelsizer.Add(self.panel_RGB, 1, wx.LEFT | wx.EXPAND, border=8)
        self.panel_XYZ = BitmapBackgroundPanel(self.panel, size=(256, 256), style=style)
        self.panel_XYZ.scalebitmap = (True, True)
        self.panel_XYZ.SetBitmap(get_bitmap("theme/checkerboard-32x32x5-333-444"))
        panelsizer.Add(self.panel_XYZ, 1, wx.RIGHT | wx.EXPAND, border=8)
        sizer = wx.BoxSizer(wx.HORIZONTAL)
        self.back_btn = FlatShadedButton(
            self.panel, bitmap=get_icon(10, "back"), label="", fgcolour=FGCOLOUR
        )
        self.back_btn.Bind(wx.EVT_BUTTON, self.back_btn_handler)
        sizer.Add(self.back_btn, 0, wx.LEFT | wx.RIGHT, border=8)
        self.label_index = wx.StaticText(self.panel, wx.ID_ANY, " ")
        self.label_index.SetForegroundColour(FGCOLOUR)
        sizer.Add(self.label_index, 0, wx.ALIGN_CENTER_VERTICAL)
        self.next_btn = FlatShadedButton(
            self.panel, bitmap=get_icon(10, "play"), label="", fgcolour=FGCOLOUR
        )
        self.next_btn.Bind(wx.EVT_BUTTON, self.next_btn_handler)
        sizer.Add(self.next_btn, 0, wx.LEFT, border=8)
        sizer.Add((12, 1), 1)
        self.measure_auto_cb = CustomCheckBox(
            self.panel, wx.ID_ANY, lang.getstr("auto")
        )
        self.measure_auto_cb.SetForegroundColour(FGCOLOUR)
        self.measure_auto_cb.Bind(wx.EVT_CHECKBOX, self.measure_auto_ctrl_handler)
        # only vertical alignment flags can be used with a horizontal sizer
        sizer.Add(self.measure_auto_cb, 0, wx.ALIGN_CENTER_VERTICAL)
        panelsizer.Add(sizer, 0, wx.BOTTOM | wx.EXPAND, border=8)
        sizer = wx.BoxSizer(wx.HORIZONTAL)
        self.measure_btn = FlatShadedButton(
            self.panel,
            bitmap=get_icon(10, "play"),
            label=lang.getstr("measure"),
            fgcolour=FGCOLOUR,
        )
        self.measure_btn.Bind(wx.EVT_BUTTON, self.measure_btn_handler)
        sizer.Add(self.measure_btn, 0, wx.RIGHT, border=6)
        # Sound when measuring
        # Needs to be stereo!
        self.measurement_sound = audio.Sound(get_data_path("beep.wav"))
        self.commit_sound = audio.Sound(get_data_path("camera_shutter.wav"))
        bitmap = self.get_sound_on_off_btn_bitmap()
        self.sound_on_off_btn = FlatShadedButton(
            self.panel, bitmap=bitmap, fgcolour=FGCOLOUR
        )
        self.sound_on_off_btn.SetToolTipString(lang.getstr("measurement.play_sound"))
        self.sound_on_off_btn.Bind(wx.EVT_BUTTON, self.measurement_play_sound_handler)
        sizer.Add(self.sound_on_off_btn, 0)
        sizer.Add((12, 1), 1)
        self.finish_btn = FlatShadedButton(
            self.panel, label=lang.getstr("finish"), fgcolour=FGCOLOUR
        )
        self.finish_btn.Bind(wx.EVT_BUTTON, self.finish_btn_handler)
        sizer.Add(self.finish_btn, 0, wx.RIGHT, border=8)
        panelsizer.Add(sizer, 0, wx.BOTTOM | wx.EXPAND, border=8)

        self.grid = CustomGrid(self, -1, size=(536, 256))
        self.grid.DisableDragColSize()
        self.grid.DisableDragRowSize()
        self.grid.SetScrollRate(0, 5)
        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.draw_horizontal_grid_lines = False
        self.grid.draw_vertical_grid_lines = False
        self.grid.style = ""
        self.grid.CreateGrid(0, 9)
        self.grid.SetRowLabelSize(62)
        for i in range(9):
            if i in (3, 4):
                size = self.grid.GetDefaultRowSize()
                if i == 4:
                    attr = wx.grid.GridCellAttr()
                    attr.SetBackgroundColour(wx.Colour(0, 0, 0, 0))
                    self.grid.SetColAttr(i, attr)
            else:
                size = 62
            self.grid.SetColSize(i, size)
        for i, label in enumerate(["R", "G", "B", "", "", "L*", "a*", "b*", ""]):
            self.grid.SetColLabelValue(i, label)
        self.grid.SetCellHighlightPenWidth(0)
        self.grid.SetDefaultCellBackgroundColour(self.grid.GetLabelBackgroundColour())
        font = self.grid.GetDefaultCellFont()
        if font.PointSize > 11:
            font.PointSize = 11
            self.grid.SetDefaultCellFont(font)
        self.grid.SetSelectionMode(wx.grid.Grid.wxGridSelectRows)
        self.grid.EnableEditing(False)
        self.grid.EnableGridLines(False)
        self.grid.Bind(wx.grid.EVT_GRID_LABEL_LEFT_CLICK, self.grid_left_click_handler)
        self.grid.Bind(wx.grid.EVT_GRID_SELECT_CELL, self.grid_left_click_handler)
        self.sizer.Add(self.grid, 1, wx.EXPAND)

        self.Fit()
        self.SetMinSize(self.GetSize())

        self.keyhandler = keyhandler
        self.id_to_keycode = {}
        if sys.platform == "darwin":
            # Use an accelerator table for tab, space, 0-9, A-Z, numpad,
            # navigation keys and processing keys
            keycodes = [wx.WXK_TAB, wx.WXK_SPACE]
            keycodes.extend(list(range(ord("0"), ord("9"))))
            keycodes.extend(list(range(ord("A"), ord("Z"))))
            keycodes.extend(NUMPAD_KEYCODES)
            keycodes.extend(NAV_KEYCODES)
            keycodes.extend(PROCESSING_KEYCODES)
            for keycode in keycodes:
                self.id_to_keycode[wx.Window.NewControlId()] = keycode
            accels = []
            for id_ in self.id_to_keycode:
                keycode = self.id_to_keycode[id_]
                self.Bind(wx.EVT_MENU, self.key_handler, id=id_)
                accels.append((wx.ACCEL_NORMAL, keycode, id_))
                if keycode == wx.WXK_TAB:
                    accels.append((wx.ACCEL_SHIFT, keycode, id_))
            self.SetAcceleratorTable(wx.AcceleratorTable(accels))
        else:
            self.Bind(wx.EVT_CHAR_HOOK, self.key_handler)

        self.Bind(wx.EVT_KEY_DOWN, self.key_handler)

        # Event handlers
        self.Bind(wx.EVT_CLOSE, self.OnClose, self)
        self.Bind(wx.EVT_MOVE, self.OnMove, self)
        self.Bind(wx.EVT_SIZE, self.OnResize, self)
        self.timer = wx.Timer(self)
        if handler:
            self.Bind(wx.EVT_TIMER, handler, self.timer)
        self.Bind(wx.EVT_WINDOW_DESTROY, self.OnDestroy, self)

        # Final initialization steps
        for child in list(self.GetAllChildren()):
            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.logger = get_file_logger("untethered")
        self._setup()

        self.Show()

        if start_timer:
            self.start_timer()

    def EndModal(self, returncode=wx.ID_OK):
        """End the untethered frame modal state.

        Args:
            returncode (int): The return code to indicate the result of the
                operation.
        """
        return returncode

    def MakeModal(self, modal=False):
        """Make the untethered frame modal or not.

        Args:
            modal (bool): Whether to make the untethered frame modal.
        """

    def OnClose(self, event):
        """Handle the close event to stop the timer and save configuration.

        Args:
            event (wx.Event): The event that triggered the action.
        """
        config.writecfg()
        if not self.timer.IsRunning():
            self.Destroy()
        else:
            self.keepGoing = False

    def OnDestroy(self, event):
        """Handle the destruction of the untethered frame.

        Args:
            event (wx.Event): The event that triggered the action.

        Returns:
            int: Return code indicating the success of the operation.
        """
        self.stop_timer()
        del self.timer
        if not hasattr(wx.Window, "UnreserveControlId"):
            return 0

        for id_ in self.id_to_keycode:
            if id_ >= 0:
                continue
            try:
                wx.Window.UnreserveControlId(id_)
            except wx.wxAssertionError as exception:
                print(exception)

        return 0

    def OnMove(self, event):
        """Handle the move event to save the position of the untethered frame.

        Args:
            event (wx.Event): The event that triggered the action.
        """
        if (
            self.IsShownOnScreen()
            and not self.IsIconized()
            and (not self.GetParent() or not self.GetParent().IsShownOnScreen())
        ):
            prev_x = getcfg("position.progress.x")
            prev_y = getcfg("position.progress.y")
            x, y = self.GetScreenPosition()
            if x != prev_x or y != prev_y:
                setcfg("position.progress.x", x)
                setcfg("position.progress.y", y)

    def OnResize(self, event):
        """Handle the resize event to adjust the grid size.

        Args:
            event (wx.Event): The event that triggered the action.
        """
        wx.CallAfter(self.resize_grid)
        event.Skip()

    def Pulse(self, msg=""):
        """Pulse the untethered frame with a message.

        Args:
            msg (str): The message to display in the pulse.

        Returns:
            tuple: A tuple containing a boolean indicating whether to keep going
        """
        self.label_RGB.SetLabel(msg)
        if msg == lang.getstr("instrument.initializing"):
            self.label_RGB.SetLabel(msg)
        return self.keepGoing, False

    def Resume(self):
        """Resume the untethered frame after a pause."""
        self.keepGoing = True
        self.set_sound_on_off_btn_bitmap()

    def UpdateProgress(self, value, msg=""):
        """Update the progress with a value and a message.

        Args:
            value (int): The value to display in the progress bar.
            msg (str): The message to display in the progress bar.

        Returns:
            tuple: A tuple containing a boolean indicating whether to keep going
        """
        return self.Pulse(msg)

    def UpdatePulse(self, msg=""):
        """Update the pulse with a  message.

        Args:
            msg (str): The message to display in the pulse.

        Returns:
            tuple: A tuple containing a boolean indicating whether to keep going
        """
        return self.Pulse(msg)

    def back_btn_handler(self, event):
        """Handle the back button click event.

        Args:
            event (wx.Event): The event that triggered the action, if any.
        """
        if self.index > 0:
            self.update(self.index - 1)

    def enable_btns(self, enable=True, enable_measure_button=False):
        """Enable or disable buttons based on the measurement state.

        Args:
            enable (bool): Whether to enable the buttons.
            enable_measure_button (bool): Whether to enable the measure button.
        """
        self.is_measuring = not enable and enable_measure_button
        self.back_btn.Enable(enable and self.index > 0)
        self.next_btn.Enable(enable and self.index < self.index_max)
        self.measure_btn._bitmap = get_icon(
            10, {True: "play", False: "pause"}.get(enable)
        )
        self.measure_btn.Enable(enable or enable_measure_button)
        self.measure_btn.SetDefault()
        if self.measure_btn.Enabled and not isinstance(
            self.FindFocus(), (wx.Control, CustomGrid)
        ):
            self.measure_btn.SetFocus()

    def finish_btn_handler(self, event):
        """Handle the finish button click event.

        Args:
            event (wx.Event): The event that triggered the action, if any.
        """
        self.finish_btn.Disable()
        self.cgats[0].type = b"CTI3"
        self.cgats[0].add_keyword("COLOR_REP", "RGB_XYZ")
        if self.white_XYZ[1] > 0:
            # Normalize to Y = 100
            query = self.cgats[0].DATA
            for i in query:
                XYZ = query[i]["XYZ_X"], query[i]["XYZ_Y"], query[i]["XYZ_Z"]
                XYZ = [v / self.white_XYZ[1] * 100 for v in XYZ]
                query[i]["XYZ_X"], query[i]["XYZ_Y"], query[i]["XYZ_Z"] = XYZ
            normalized = "YES"
        else:
            normalized = "NO"
        self.cgats[0].add_keyword("NORMALIZED_TO_Y_100", normalized)
        self.cgats[0].add_keyword("DEVICE_CLASS", "DISPLAY")
        self.cgats[0].add_keyword("INSTRUMENT_TYPE_SPECTRAL", "NO")
        if hasattr(self.cgats[0], "APPROX_WHITE_POINT"):
            self.cgats[0].remove_keyword("APPROX_WHITE_POINT")
        # Remove L*a*b* from DATA_FORMAT if present
        for i, label in reversed(list(self.cgats[0].DATA_FORMAT.items())):
            if label.startswith(b"LAB_"):
                self.cgats[0].DATA_FORMAT.pop(i)
        # Add XYZ to DATA_FORMAT if not yet present
        for label in (b"XYZ_X", b"XYZ_Y", b"XYZ_Z"):
            if label not in list(self.cgats[0].DATA_FORMAT.values()):
                self.cgats[0].DATA_FORMAT.add_data((label,))
        self.cgats[0].write(os.path.splitext(self.cgats.filename)[0] + ".ti3")
        self.safe_send("Q")
        time.sleep(0.5)
        self.safe_send("Q")

    def flush(self):
        """Flush the worker subprocess output."""

    def get_Lab_RGB(self):
        """Calculate the Lab and RGB values for the current patch.

        Returns:
            tuple: A tuple containing the Lab and RGB values for the current patch.
        """
        row = self.cgats[0].DATA[self.index]
        XYZ = row["XYZ_X"], row["XYZ_Y"], row["XYZ_Z"]
        self.last_XYZ = XYZ
        Lab = colormath.XYZ2Lab(*XYZ)
        if self.white_XYZ[1] > 0:
            XYZ = [v / self.white_XYZ[1] * 100 for v in XYZ]
            white_XYZ_Y100 = [v / self.white_XYZ[1] * 100 for v in self.white_XYZ]
            white_CCT = colormath.XYZ2CCT(*white_XYZ_Y100)
            if white_CCT:
                DXYZ = colormath.CIEDCCT2XYZ(white_CCT, scale=100.0)
                if DXYZ:
                    white_CIEDCCT_Lab = colormath.XYZ2Lab(*DXYZ)
                PXYZ = colormath.planckianCT2XYZ(white_CCT, scale=100.0)
                if PXYZ:
                    white_planckianCCT_Lab = colormath.XYZ2Lab(*PXYZ)
                white_Lab = colormath.XYZ2Lab(*white_XYZ_Y100)
                if (
                    DXYZ
                    and PXYZ
                    and (
                        colormath.delta(*white_CIEDCCT_Lab + white_Lab)["E"] < 6
                        or colormath.delta(*white_planckianCCT_Lab + white_Lab)["E"] < 6
                    )
                ):
                    # Is white close enough to daylight or planckian locus?
                    XYZ = colormath.adapt(XYZ[0], XYZ[1], XYZ[2], white_XYZ_Y100, "D65")
        X, Y, Z = [v / 100.0 for v in XYZ]
        color = [round(v) for v in colormath.XYZ2RGB(X, Y, Z, scale=255)]
        return Lab, color

    def grid_left_click_handler(self, event):
        """Handle left click events on the grid.

        Args:
            event (wx.grid.GridEvent): The event that triggered the action.
        """
        if not self.is_measuring:
            row, col = event.GetRow(), event.GetCol()
            if row == -1 and col > -1:  # col label clicked
                pass
            elif row > -1:  # row clicked
                if not (event.CmdDown() or event.ControlDown() or event.ShiftDown()):
                    self.update(row)
                event.Skip()

    def has_worker_subprocess(self):
        """Check if the untethered frame has a worker subprocess.

        Returns:
            bool: True if the frame has a worker subprocess, False otherwise.
        """
        return bool(
            getattr(self, "worker", None) and getattr(self.worker, "subprocess", None)
        )

    def isatty(self):
        """Check if the untethered frame is interactive.

        Returns:
            bool: True if the frame is interactive, False otherwise.
        """
        return True

    def key_handler(self, event):
        """Handle key events for navigation and measurement control.

        Args:
            event (wx.KeyEvent): The event that triggered the action.
        """
        keycode = None
        is_key_event = event.GetEventType() in (
            wx.EVT_CHAR.typeId,
            wx.EVT_CHAR_HOOK.typeId,
            wx.EVT_KEY_DOWN.typeId,
        )
        if is_key_event:
            keycode = event.GetKeyCode()
        elif event.GetEventType() == wx.EVT_MENU.typeId:
            keycode = self.id_to_keycode.get(event.GetId())
        if keycode == wx.WXK_TAB:
            self.global_navigate() or event.Skip()
        elif keycode >= 0:
            if keycode in (wx.WXK_UP, wx.WXK_NUMPAD_UP):
                self.back_btn_handler(None)
            elif keycode in (wx.WXK_DOWN, wx.WXK_NUMPAD_DOWN):
                self.next_btn_handler(None)
            elif keycode in (wx.WXK_HOME, wx.WXK_NUMPAD_HOME):
                if self.index > -1:
                    self.update(0)
            elif keycode in (wx.WXK_END, wx.WXK_NUMPAD_END):
                if self.index_max > -1:
                    self.update(self.index_max)
            elif keycode in (wx.WXK_PAGEDOWN, wx.WXK_NUMPAD_PAGEDOWN):
                if self.index > -1:
                    self.grid.MovePageDown()
                    self.update(self.grid.GetGridCursorRow())
            elif keycode in (wx.WXK_PAGEUP, wx.WXK_NUMPAD_PAGEUP):
                if self.index > -1:
                    self.grid.MovePageUp()
                    self.update(self.grid.GetGridCursorRow())
            elif is_key_event and (event.ControlDown() or event.CmdDown()):
                event.Skip()
            elif self.has_worker_subprocess() and keycode < 256:
                if keycode == wx.WXK_ESCAPE or chr(keycode) == "Q":
                    # ESC or Q
                    self.worker.abort_subprocess()
                elif (
                    not isinstance(self.FindFocus(), wx.Control)
                    or keycode != wx.WXK_SPACE
                ):
                    # Any other key
                    self.measure_btn_handler(None)
                else:
                    event.Skip()
            else:
                event.Skip()
        else:
            event.Skip()

    def measure(self, event=None):
        """Start or stop the measurement process.

        Args:
            event (wx.Event): The event that triggered the action, if any.
        """
        self.enable_btns(False, True)
        # Use a delay to allow for TFT lag
        wx.CallLater(200, self.safe_send, " ")

    def measure_auto_ctrl_handler(self, event):
        """Handle the auto measurement checkbox change event.

        Args:
            event (wx.Event): The event that triggered the action, if any.
        """
        auto = self.measure_auto_cb.GetValue()
        setcfg("untethered.measure.auto", int(auto))

    def measure_btn_handler(self, event):
        """Handle the measure button click event.

        Args:
            event (wx.Event): The event that triggered the action, if any.
        """
        if self.is_measuring:
            self.is_measuring = False
        else:
            self.last_XYZ = (-1, -1, -1)
            self.measure_count = 1
            self.measure()

    def measurement_play_sound_handler(self, event):
        """Toggle the sound on/off setting and update the button bitmap.

        Args:
            event (wx.Event): The event that triggered the action, if any.
        """
        setcfg(
            "measurement.play_sound", int(not (bool(getcfg("measurement.play_sound"))))
        )
        self.set_sound_on_off_btn_bitmap()

    def get_sound_on_off_btn_bitmap(self):
        """Get the bitmap for the sound on/off button based on the current setting.

        Returns:
            wx.Bitmap: The bitmap for the sound on/off button.
        """
        if getcfg("measurement.play_sound"):
            bitmap = get_icon(16, "sound_volume_full")
        else:
            bitmap = get_icon(16, "sound_off")
        return bitmap

    def set_sound_on_off_btn_bitmap(self):
        """Set the bitmap for the sound on/off button based on the current setting."""
        bitmap = self.get_sound_on_off_btn_bitmap()
        self.sound_on_off_btn._bitmap = bitmap

    def next_btn_handler(self, event):
        """Handle the next button click event.

        Args:
            event (wx.Event): The event that triggered the action, if any.
        """
        if self.index < self.index_max:
            self.update(self.index + 1)

    def parse_txt(self, txt):
        """Parse the text output from the worker subprocess.

        Args:
            txt (str): The text output to parse.
        """
        if not txt:
            return
        self.logger.info(f"{txt!r}")
        data_len = len(self.cgats[0].DATA)
        if self.grid.GetNumberRows() < data_len:
            self.index = 0
            self.index_max = data_len - 1
            self.grid.AppendRows(data_len - self.grid.GetNumberRows())
            for i in self.cgats[0].DATA:
                self.grid.SetRowLabelValue(i, f"{i + 1}")
                row = self.cgats[0].DATA[i]
                RGB = []
                for j, label in enumerate("RGB"):
                    value = round(row[f"RGB_{label}"] / 100.0 * 255)
                    self.grid.SetCellValue(row.SAMPLE_ID - 1, j, f"{value}")
                    RGB.append(value)
                self.grid.SetCellBackgroundColour(row.SAMPLE_ID - 1, 3, wx.Colour(*RGB))
        if "Connecting to the instrument" in txt:
            self.Pulse(lang.getstr("instrument.initializing"))
        if "Spot read needs a calibration" in txt:
            self.is_measuring = False
        if "Spot read failed" in txt:
            self.last_error = txt

        # Result is XYZ: d.dddddd d.dddddd d.dddddd, D50 Lab: d.dddddd d.dddddd d.dddddd
        if "Result is XYZ:" in txt:
            self.last_error = None
            if getcfg("measurement.play_sound"):
                self.measurement_sound.safe_play()
            XYZ = re.search(
                r"XYZ:\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)", txt
            )
            if not XYZ:
                return

            def is_white(r):
                return r["RGB_R"] == 100 and r["RGB_G"] == 100 and r["RGB_B"] == 100

            XYZ = [float(v) for v in XYZ.groups()]
            row = self.cgats[0].DATA[self.index]
            if is_white(row) and XYZ[1] > 0:
                self.cgats[0].add_keyword(
                    "LUMINANCE_XYZ_CDM2", "{:.6f} {:.6f} {:.6f}".format(*tuple(XYZ))
                )
                self.white_XYZ = XYZ
            Lab1 = colormath.XYZ2Lab(*self.last_XYZ)
            Lab2 = colormath.XYZ2Lab(*XYZ)
            delta = colormath.delta(*Lab1 + Lab2)
            if DEBUG or TEST or VERBOSE > 1:
                print("Last recorded Lab: {:.4f} {:.4f} {:.4f}".format(*Lab1))
                print("Current Lab: {:.4f} {:.4f} {:.4f}".format(*Lab2))
                print(f"Delta E to last recorded Lab: {delta['E']:.4f}")
                print(f"Abs. delta L to last recorded Lab: {abs(delta['L']):.4f}")
                print(f"Abs. delta C to last recorded Lab: {abs(delta['C']):.4f}")
            consecutive_white_patch = (
                self.index
                and is_white(row)
                and is_white(self.cgats[0].DATA[self.index - 1])
            )
            measurement_exceeds_delta = delta["E"] > getcfg("untethered.min_delta") or (
                abs(delta["L"]) > getcfg("untethered.min_delta.lightness")
                and abs(delta["C"]) < getcfg("untethered.max_delta.chroma")
            )
            if consecutive_white_patch or measurement_exceeds_delta:
                self.measure_count += 1
                if self.measure_count == 2:
                    if getcfg("measurement.play_sound"):
                        self.commit_sound.safe_play()
                    self.measure_count = 0
                    # Reset row label
                    self.grid.SetRowLabelValue(self.index, f"{self.index + 1}")
                    # Update CGATS
                    query = self.cgats[0].queryi1(
                        {
                            "RGB_R": row["RGB_R"],
                            "RGB_G": row["RGB_G"],
                            "RGB_B": row["RGB_B"],
                            "SAMPLE_ID": row["SAMPLE_ID"],
                        }
                    )
                    if query:
                        index = query.SAMPLE_ID - 1
                        if index not in self.measured:
                            self.measured.append(index)
                        query["XYZ_X"], query["XYZ_Y"], query["XYZ_Z"] = XYZ
                    if getcfg("untethered.measure.auto"):
                        self.show_RGB(False, False)
                    self.show_XYZ()
                    Lab, color = self.get_Lab_RGB()
                    self.grid.SetCellBackgroundColour(
                        query.SAMPLE_ID - 1, 4, wx.Colour(*color)
                    )
                    for j in range(3):
                        self.grid.SetCellValue(
                            query.SAMPLE_ID - 1, 5 + j, f"{Lab[j]:.2f}"
                        )
                    self.grid.MakeCellVisible(self.index, 0)
                    self.grid.ForceRefresh()
                    if len(self.measured) == data_len:
                        self.finished = True
                        self.finish_btn.Enable()
                    else:
                        # Jump to the next or previous unmeasured patch, if any
                        index = self.index
                        for i in range(self.index + 1, data_len):
                            if (
                                getcfg("untethered.measure.auto")
                                or i not in self.measured
                            ):
                                self.index = i
                                break
                        if self.index == index:
                            for i in range(self.index - 1, -1, -1):
                                if i not in self.measured:
                                    self.index = i
                                    break
                        if self.index != index:
                            # Mark the row containing the next/previous patch
                            self.grid.SetRowLabelValue(
                                self.index, f"\u25ba {self.index + 1}"
                            )
                            self.grid.MakeCellVisible(self.index, 0)
        if "key to take a reading" in txt and not self.last_error:
            if getcfg("untethered.measure.auto") and self.is_measuring:
                if not self.finished and self.keepGoing:
                    self.measure()
                else:
                    self.enable_btns()
            else:
                show_XYZ = self.index in self.measured
                delay = getcfg("untethered.measure.manual.delay") * 1000
                wx.CallLater(delay, self.show_RGB, not show_XYZ)
                if show_XYZ:
                    wx.CallLater(delay, self.show_XYZ)
                wx.CallLater(delay, self.enable_btns)

    def pause_continue_handler(self, event=None):
        """Handle pause/continue action.

        Args:
            event: The event that triggered the action, if any.
        """
        if not event:
            self.parse_txt(self.worker.lastmsg.read())

    @property
    def paused(self):
        """Check if the untethered frame is paused.

        Returns:
            bool: True if the frame is paused, False otherwise.
        """
        return False

    def reset(self):
        """Reset the untethered frame to its initial state."""
        self._setup()

    def resize_grid(self):
        """Resize the grid to fit the window."""
        num_cols = self.grid.GetNumberCols()
        if not num_cols:
            return
        grid_w = self.grid.GetSize()[0] - self.grid.GetDefaultRowSize() * 2
        col_w = round(grid_w / (num_cols - 1))
        last_col_w = grid_w - col_w * (num_cols - 2)
        self.grid.SetRowLabelSize(col_w)
        for i in range(num_cols):
            if i in (3, 4):
                w = self.grid.GetDefaultRowSize()
            elif i == num_cols - 1:
                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()

    def _setup(self):
        """Initial setup of the untethered frame."""
        self.logger.info("-" * 80)
        self.is_measuring = False
        self.keepGoing = True
        self.last_error = None
        self.index = -1
        self.index_max = -1
        self.last_XYZ = (-1, -1, -1)
        self.white_XYZ = (-1, -1, -1)
        self.measure_count = 0
        self.measured = []
        self.finished = False
        self.label_RGB.SetLabel(" ")
        self.label_XYZ.SetLabel(" ")
        self.panel_RGB.SetBackgroundColour(BGCOLOUR)
        self.panel_RGB.Refresh()
        self.panel_RGB.Update()
        self.panel_XYZ.SetBackgroundColour(BGCOLOUR)
        self.panel_XYZ.Refresh()
        self.panel_XYZ.Update()
        self.label_index.SetLabel(" ")
        self.enable_btns(False)
        self.measure_auto_cb.SetValue(bool(getcfg("untethered.measure.auto")))
        self.finish_btn.Disable()

        if self.grid.GetNumberRows():
            self.grid.DeleteRows(0, self.grid.GetNumberRows())

        # Set position
        x = getcfg("position.progress.x")
        y = getcfg("position.progress.y")
        self.SetSaneGeometry(x, y)

    def safe_send(self, bytes_):
        """Send bytes to the worker subprocess if it exists and is not aborted.

        Args:
            bytes_ (bytes): The bytes to send.
        """
        if self.has_worker_subprocess() and not self.worker.subprocess_abort:
            self.worker.safe_send(bytes_)

    def show_RGB(self, clear_XYZ=True, mark_current_row=True):
        """Display the RGB values and color for the current patch.

        Args:
            clear_XYZ (bool): Whether to clear the XYZ display.
            mark_current_row (bool): Whether to mark the current row in the
                grid.
        """
        row = self.cgats[0].DATA[self.index]
        self.label_RGB.SetLabel(
            "RGB {} {} {}".format(
                round(row["RGB_R"] / 100.0 * 255),
                round(row["RGB_G"] / 100.0 * 255),
                round(row["RGB_B"] / 100.0 * 255),
            )
        )
        color = [
            round(v / 100.0 * 255) for v in (row["RGB_R"], row["RGB_G"], row["RGB_B"])
        ]
        self.panel_RGB.SetBackgroundColour(wx.Colour(*color))
        self.panel_RGB.SetBitmap(None)
        self.panel_RGB.Refresh()
        self.panel_RGB.Update()
        if clear_XYZ:
            self.label_XYZ.SetLabel(" ")
            self.panel_XYZ.SetBackgroundColour(BGCOLOUR)
            self.panel_XYZ.SetBitmap(get_bitmap("theme/checkerboard-32x32x5-333-444"))
            self.panel_XYZ.Refresh()
            self.panel_XYZ.Update()
        if mark_current_row:
            self.grid.SetRowLabelValue(self.index, f"\u25ba {self.index + 1}")
            self.grid.MakeCellVisible(self.index, 0)
        if self.index not in self.grid.GetSelectedRows():
            self.grid.SelectRow(self.index)
            self.grid.SetGridCursor(self.index, 0)
        self.label_index.SetLabel(f"{self.index + 1}/{len(self.cgats[0].DATA)}")
        self.label_index.GetContainingSizer().Layout()

    def show_XYZ(self):
        """Display the XYZ values and color for the current patch."""
        Lab, color = self.get_Lab_RGB()
        self.label_XYZ.SetLabel("L*a*b* {:.2f} {:.2f} {:.2f}".format(*Lab))
        self.panel_XYZ.SetBackgroundColour(wx.Colour(*color))
        self.panel_XYZ.SetBitmap(None)
        self.panel_XYZ.Refresh()
        self.panel_XYZ.Update()

    def start_timer(self, ms=50):
        """Start the timer with the given interval in milliseconds.

        Args:
            ms (int): The interval in milliseconds to trigger the timer.
        """
        self.timer.Start(ms)

    def stop_timer(self):
        """Stop the timer."""
        self.timer.Stop()

    def update(self, index):
        """Update the display with the given index.

        Args:
            index (int): The index of the patch to update.
        """
        # Reset row label
        self.grid.SetRowLabelValue(self.index, f"{self.index + 1}")

        self.index = index
        show_XYZ = self.index in self.measured
        self.show_RGB(not show_XYZ)
        if show_XYZ:
            self.show_XYZ()
        self.enable_btns()

    def write(self, txt):
        """Write text to the untethered frame.

        Args:
            txt (str): The text to write.
        """
        wx.CallAfter(self.parse_txt, txt)
