"""Graphical interface for setting the measurement area in DisplayCAL.

It includes functionality for resizing, centering, and zooming the measurement
frame, as well as handling display geometry and configurations. The module
integrates with wxPython for user interaction and supports various display
environments.
"""

import math
import os
import sys
import time
import warnings

from DisplayCAL import config
from DisplayCAL import localization as lang
from DisplayCAL.config import (
    DEFAULTS,
    SCALE_ADJUSTMENT_FACTOR,
    get_argyll_display_number,
    get_default_dpi,
    get_display_number,
    get_display_rects,
    get_icon,
    getcfg,
    setcfg,
    writecfg,
)
from DisplayCAL.debughelpers import handle_error
from DisplayCAL.meta import NAME as APPNAME
from DisplayCAL.options import DEBUG
from DisplayCAL.util_list import floatlist, strlist
from DisplayCAL.wx_addons import wx
from DisplayCAL.wx_fixes import GenBitmapButton as BitmapButton
from DisplayCAL.wx_windows import (
    BaseApp,
    BitmapBackgroundPanel,
    ConfirmDialog,
    InfoDialog,
    InvincibleFrame,
)

try:
    from DisplayCAL import real_display_size_mm
except ImportError as exception:
    real_display_size_mm = None
    warnings.warn(str(exception), Warning, stacklevel=2)


def get_default_size():
    """Get and return the default size for the window in pixels.

    The default size is always equivalent to 100 x 100 mm according
    to the display's size as returned by the real_display_size_mm() function,
    which uses the same code as Argyll to determine that size.

    This function is used internally.
    """
    display_sizes = []
    display_sizes_mm = []
    for display_no in range(len(getcfg("displays"))):
        display_no = get_display_number(display_no)
        display_size = wx.Display(display_no).Geometry[2:]
        display_size_mm = []
        if real_display_size_mm:
            try:
                display_size_mm = real_display_size_mm.real_display_size_mm(display_no)
            except Exception as exception:
                handle_error(
                    f"Error - real_display_size_mm() failed: {exception}", silent=True
                )
            else:
                display_size_mm = floatlist(display_size_mm)
        if DEBUG:
            print("[D]  display_size_mm:", display_size_mm)
        if not len(display_size_mm) or 0 in display_size_mm:
            ppi_def = get_default_dpi()
            method = 1
            if method == 0:
                # use configurable screen diagonal
                inch = 20.0
                mm = inch * 25.4
                f = mm / math.sqrt(
                    math.pow(display_size[0], 2) + math.pow(display_size[1], 2)
                )
                w_mm = math.sqrt(math.pow(mm, 2) - math.pow(display_size[1] * f, 2))
                h_mm = math.sqrt(math.pow(mm, 2) - math.pow(display_size[0] * f, 2))
                display_size_mm = w_mm, h_mm
            elif method == 1:
                # use the first display
                display_size_1st = wx.DisplaySize()
                display_size_mm = floatlist(wx.DisplaySizeMM())
                if 0 in display_size_mm:
                    # bogus
                    display_size_mm = [
                        float(display_size_1st[0]) / ppi_def * 25.4,
                        float(display_size_1st[1]) / ppi_def * 25.4,
                    ]
                if display_no > 0:
                    display_size_mm[0] = float(display_size[0]) / (
                        display_size_1st[0] / display_size_mm[0]
                    )
                    display_size_mm[1] = float(display_size[1]) / (
                        display_size_1st[1] / display_size_mm[1]
                    )
            else:
                # use assumed ppi
                display_size_mm = (
                    float(display_size[0]) / ppi_def * 25.4,
                    float(display_size[1]) / ppi_def * 25.4,
                )
        display_sizes.append(display_size)
        display_sizes_mm.append(display_size_mm)
    if (
        sum(mm[0] for mm in display_sizes_mm) / len(display_sizes_mm)
        == display_sizes_mm[0][0]
        and sum(mm[1] for mm in display_sizes_mm) / len(display_sizes_mm)
        == display_sizes_mm[0][1]
    ):
        # display_size_mm is the same for all screens, use the 1st one
        display_size = display_sizes[0]
        display_size_mm = display_sizes_mm[0]
    else:
        if getcfg("display_lut.link"):
            display_no = getcfg("display.number") - 1
        else:
            display_no = getcfg("display_lut.number") - 1
        display_size = display_sizes[display_no]
        display_size_mm = display_sizes_mm[display_no]
    px_per_mm = (
        display_size[0] / display_size_mm[0],
        display_size[1] / display_size_mm[1],
    )
    if DEBUG:
        print("[D]  H px_per_mm:", px_per_mm[0])
        print("[D]  V px_per_mm:", px_per_mm[1])
    return round(100.0 * max(px_per_mm))


class MeasureFrame(InvincibleFrame):
    """A rectangular window to set the measure area size for dispcal/dispread."""

    exitcode = 1

    def __init__(self, parent=None, id=-1):  # noqa: A002
        style = wx.DEFAULT_FRAME_STYLE & ~(wx.RESIZE_BORDER | wx.MAXIMIZE_BOX)
        if os.getenv("XDG_SESSION_TYPE") != "wayland" and getcfg(
            "patterngenerator.use_pattern_window"
        ):
            style |= wx.STAY_ON_TOP
        InvincibleFrame.__init__(
            self,
            parent,
            id,
            lang.getstr("measureframe.title"),
            style=style,
            name="measureframe",
        )
        self.SetIcons(config.get_icon_bundle([256, 48, 32, 16], APPNAME))
        self.Bind(wx.EVT_CLOSE, self.close_handler, self)
        if os.getenv("XDG_SESSION_TYPE") != "wayland":
            self.Bind(wx.EVT_MOVE, self.move_handler, self)
        self.Bind(wx.EVT_SHOW, self.show_handler)
        self.Bind(wx.EVT_KILL_FOCUS, self.focus_lost_handler)
        self.Bind(wx.EVT_SET_FOCUS, self.focus_handler)
        self.panel = BitmapBackgroundPanel(self)
        self.panel.scalebitmap = (False, False)

        if os.getenv("XDG_SESSION_TYPE") == "wayland":
            self.sizer = wx.FlexGridSizer(3, 1, 0, 0)
            self.sizer.AddGrowableCol(0)
            self.sizer.AddGrowableRow(1)
        else:
            self.sizer = wx.GridSizer(3, 1, 0, 0)
        self.panel.SetSizer(self.sizer)

        self.hsizer = wx.BoxSizer(wx.HORIZONTAL)
        self.sizer.Add(self.hsizer, flag=wx.ALIGN_CENTER_HORIZONTAL | wx.ALIGN_TOP)

        self.zoommaxbutton = BitmapButton(
            self.panel,
            -1,
            get_icon(16, "zoom-best-fit"),
            style=wx.NO_BORDER,
            name="zoommaxbutton",
        )
        self.zoommaxbutton.Bind(wx.EVT_KILL_FOCUS, self.focus_lost_handler)
        self.Bind(wx.EVT_BUTTON, self.zoommax_handler, self.zoommaxbutton)
        self.hsizer.Add(
            self.zoommaxbutton,
            flag=wx.ALIGN_CENTER | wx.LEFT | wx.TOP | wx.BOTTOM,
            border=10,
        )
        self.zoommaxbutton.SetToolTipString(lang.getstr("measureframe.zoommax"))

        self.hsizer.Add((8, 0))

        self.zoominbutton = BitmapButton(
            self.panel,
            -1,
            get_icon(16, "zoom-in"),
            style=wx.NO_BORDER,
            name="zoominbutton",
        )
        self.zoominbutton.Bind(wx.EVT_KILL_FOCUS, self.focus_lost_handler)
        self.Bind(wx.EVT_BUTTON, self.zoomin_handler, self.zoominbutton)
        self.hsizer.Add(
            self.zoominbutton, flag=wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, border=10
        )
        self.zoominbutton.SetToolTipString(lang.getstr("measureframe.zoomin"))

        self.hsizer.Add((8, 0))

        self.zoomnormalbutton = BitmapButton(
            self.panel,
            -1,
            get_icon(16, "zoom-original"),
            style=wx.NO_BORDER,
            name="zoomnormalbutton",
        )
        self.zoomnormalbutton.Bind(wx.EVT_KILL_FOCUS, self.focus_lost_handler)
        self.Bind(wx.EVT_BUTTON, self.zoomnormal_handler, self.zoomnormalbutton)
        self.hsizer.Add(
            self.zoomnormalbutton, flag=wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, border=10
        )
        self.zoomnormalbutton.SetToolTipString(lang.getstr("measureframe.zoomnormal"))

        self.hsizer.Add((8, 0))

        self.zoomoutbutton = BitmapButton(
            self.panel,
            -1,
            get_icon(16, "zoom-out"),
            style=wx.NO_BORDER,
            name="zoomoutbutton",
        )
        self.zoomoutbutton.Bind(wx.EVT_KILL_FOCUS, self.focus_lost_handler)
        self.Bind(wx.EVT_BUTTON, self.zoomout_handler, self.zoomoutbutton)
        self.hsizer.Add(
            self.zoomoutbutton,
            flag=wx.ALIGN_CENTER | wx.RIGHT | wx.TOP | wx.BOTTOM,
            border=10,
        )
        self.zoomoutbutton.SetToolTipString(lang.getstr("measureframe.zoomout"))

        if os.getenv("XDG_SESSION_TYPE") == "wayland":
            # No manual centering under Wayland...
            self.centermsg = wx.StaticText(
                self.panel,
                -1,
                lang.getstr("measureframe.center.manual"),
                style=wx.ALIGN_CENTER_HORIZONTAL,
            )
            self.centermsg.Wrap(self.hsizer.MinSize[0] - 20)
            self.sizer.Add(
                self.centermsg, flag=wx.ALIGN_CENTER | wx.LEFT | wx.RIGHT, border=10
            )
        else:
            self.centerbutton = BitmapButton(
                self.panel,
                -1,
                get_icon(16, "window-center"),
                style=wx.NO_BORDER,
                name="centerbutton",
            )
            self.centerbutton.Bind(wx.EVT_KILL_FOCUS, self.focus_lost_handler)
            self.Bind(wx.EVT_BUTTON, self.center_handler, self.centerbutton)
            self.sizer.Add(
                self.centerbutton, flag=wx.ALIGN_CENTER | wx.LEFT | wx.RIGHT, border=10
            )
            self.centerbutton.SetToolTipString(lang.getstr("measureframe.center"))

        self.vsizer = wx.BoxSizer(wx.VERTICAL)
        self.sizer.Add(self.vsizer, flag=wx.ALIGN_BOTTOM | wx.ALIGN_CENTER_HORIZONTAL)

        if os.getenv("XDG_SESSION_TYPE") != "wayland" or config.is_virtual_display():
            self.measure_darken_background_cb = wx.CheckBox(
                self.panel, -1, lang.getstr("measure.darken_background")
            )
            self.measure_darken_background_cb.SetValue(
                bool(int(getcfg("measure.darken_background")))
            )
            self.measure_darken_background_cb.Bind(
                wx.EVT_KILL_FOCUS, self.focus_lost_handler
            )
            self.Bind(
                wx.EVT_CHECKBOX,
                self.measure_darken_background_ctrl_handler,
                id=self.measure_darken_background_cb.GetId(),
            )
            self.vsizer.Add(
                self.measure_darken_background_cb,
                flag=wx.ALIGN_CENTER_HORIZONTAL | wx.LEFT | wx.RIGHT,
                border=10,
            )
        else:
            self.vsizer.Add((10, 10))

        self.measurebutton = wx.Button(
            self.panel,
            -1,
            lang.getstr("measureframe.measurebutton"),
            name="measurebutton",
        )
        self.measurebutton.Bind(wx.EVT_KILL_FOCUS, self.focus_lost_handler)
        self.Bind(wx.EVT_BUTTON, self.measure_handler, self.measurebutton)
        self.vsizer.Add(
            self.measurebutton,
            # flag=wx.ALIGN_BOTTOM | wx.ALIGN_CENTER_HORIZONTAL | wx.ALL,
            flag=wx.ALIGN_CENTER_HORIZONTAL | wx.ALL,
            border=10,
        )
        self.measurebutton.SetMaxFontSize(11)
        self.measurebutton.SetDefault()
        self.last_focused = self.measurebutton

        self.display_no = wx.Display.GetFromWindow(self)
        self.display_rects = get_display_rects()

        self.sizer.SetSizeHints(self)

    def measure_darken_background_ctrl_handler(self, event):
        """Handle the checkbox event for darkening the background during measurement.

        Args:
            event (wx.Event): The event object containing information about the
                checkbox event.
        """
        if self.measure_darken_background_cb.GetValue() and getcfg(
            "measure.darken_background.show_warning"
        ):
            dlg = ConfirmDialog(
                self,
                msg=lang.getstr("measure.darken_background.warning"),
                ok=lang.getstr("ok"),
                cancel=lang.getstr("cancel"),
                bitmap=get_icon(32, "dialog-warning"),
            )
            chk = wx.CheckBox(dlg, -1, lang.getstr("dialog.do_not_show_again"))
            dlg.Bind(
                wx.EVT_CHECKBOX,
                self.measure_darken_background_warning_handler,
                id=chk.GetId(),
            )
            dlg.sizer3.Add(chk, flag=wx.TOP | wx.ALIGN_LEFT, border=12)
            dlg.sizer0.SetSizeHints(dlg)
            dlg.sizer0.Layout()
            rslt = dlg.ShowModal()
            if rslt == wx.ID_CANCEL:
                self.measure_darken_background_cb.SetValue(False)
        setcfg(
            "measure.darken_background",
            int(self.measure_darken_background_cb.GetValue()),
        )

    def measure_darken_background_warning_handler(self, event):
        """Handle the warning checkbox event for darkening the background.

        Args:
            event (wx.Event): The event object containing information about the
                checkbox event.
        """
        setcfg(
            "measure.darken_background.show_warning",
            int(not event.GetEventObject().GetValue()),
        )

    def info_handler(self, event):
        """Handle the info button click event.

        Args:
            event (wx.Event): The event object containing information about the
                info button click event.
        """
        InfoDialog(
            self,
            msg=lang.getstr("measureframe.info"),
            ok=lang.getstr("ok"),
            bitmap=get_icon(32, "dialog-information"),
            log=False,
        )

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

        Args:
            event (wx.Event): The event object containing information about the
                measure button click event.
        """
        if self.Parent and hasattr(self.Parent, "call_pending_function"):
            self.Parent.call_pending_function()
        else:
            MeasureFrame.exitcode = 255
            self.Close()

    def Show(self, show=True):
        """Show or hide the measure frame.

        Args:
            show (bool, optional): If True, show the measure frame; if False,
                hide it. Defaults to True.
        """
        if show:
            self.show_controls()
            if hasattr(self, "measure_darken_background_cb"):
                self.measure_darken_background_cb.SetValue(
                    bool(int(getcfg("measure.darken_background")))
                )
            if self.Parent and hasattr(self.Parent, "display_ctrl"):
                display_no = self.Parent.display_ctrl.GetSelection()
            else:
                display_no = getcfg("display.number") - 1
            if display_no < 0 or display_no > wx.Display.GetCount() - 1:
                display_no = 0
            else:
                display_no = get_display_number(display_no)
            x, y = wx.Display(display_no).Geometry[:2]
            self.SetPosition((x, y))  # place measure frame on correct display
            self.place_n_zoom(*floatlist(getcfg("dimensions.measureframe").split(",")))
            self.display_no = wx.Display.GetFromWindow(self)
        elif self.IsShownOnScreen():
            setcfg("dimensions.measureframe", self.get_dimensions())
            if self.Parent and hasattr(self.Parent, "get_set_display"):
                self.Parent.get_set_display()
        if isinstance(self, wx.Dialog):
            if show:
                self.ShowModal()
            elif self.IsModal():
                self.EndModal(wx.ID_OK)
            else:
                wx.Dialog.Hide(self)
        else:
            wx.Frame.Show(self, show)

    def Hide(self):
        """Hide the measure frame and save its dimensions."""
        self.Show(False)

    def place_n_zoom(self, x=None, y=None, scale=None):
        """Place and scale the window.

        x, y and scale need to be in Argyll coordinates (0.0...1.0)
        if given. Without arguments, they are read from the user
        configuration.

        Args:
            x (float, optional): The horizontal position of the measure frame
                relative to the display (0.0...1.0). Defaults to None.
            y (float, optional): The vertical position of the measure frame
                relative to the display (0.0...1.0). Defaults to None.
            scale (float, optional): The scale factor for the measure frame
                (0.0...50.0). Defaults to None.
        """
        if DEBUG:
            print("[D] measureframe.place_n_zoom")
        if None in (x, y, scale):
            cur_x, cur_y, cur_scale = floatlist(self.get_dimensions().split(","))
            if x is None:
                x = cur_x
            if y is None:
                y = cur_y
            if scale is None:
                scale = cur_scale
        scale = min(scale, 50.0)  # Argyll max
        if DEBUG:
            print("[D]  x:", x)
            print("[D]  y:", y)
            print("[D]  scale:", scale)
            print("[D]  scale_adjustment_factor:", SCALE_ADJUSTMENT_FACTOR)
        scale /= float(SCALE_ADJUSTMENT_FACTOR)
        if DEBUG:
            print("[D]  scale / scale_adjustment_factor:", scale)
        display = self.get_display(getcfg("display.number") - 1)
        display_client_rect = display[2]
        display_client_size = display_client_rect[2:]
        if DEBUG:
            print("[D]  display_client_rect:", display_client_rect)
            print("[D]  display_client_size:", display_client_size)
        measureframe_min_size = [max(self.sizer.GetMinSize())] * 2
        if DEBUG:
            print("[D]  measureframe_min_size:", measureframe_min_size)
        default_measureframe_size = get_default_size()
        DEFAULTS["size.measureframe"] = default_measureframe_size
        size = [
            min(display_client_size[0], default_measureframe_size * scale),
            min(display_client_size[1], default_measureframe_size * scale),
        ]
        if measureframe_min_size[0] > size[0]:
            size = measureframe_min_size
        size[0] = min(size[0], display_client_size[0])
        size[1] = min(size[1], display_client_size[1])
        if max(size) >= max(display_client_size):
            scale = 50
        if DEBUG:
            print("[D]  measureframe_size:", size)
        size[0] = size[1] = int(max(size))
        # Save the size we're about to set (in wx.Display.Geometry coordinate units).
        # get_dimensions() uses this to detect and correct for any mismatch between
        # Display.Geometry and GetSize()/GetScreenPosition() on Windows with DPI scaling.
        self._last_set_size = size[0]
        if (
            sys.platform not in ("darwin", "win32")
            and os.getenv("XDG_SESSION_TYPE") != "wayland"
            and not os.getenv("XDG_CURRENT_DESKTOP", "").startswith("KDE")
            and hasattr(self, "MaxClientSize")
        ):
            self.SetMaxClientSize((-1, -1))
            self.SetMinClientSize(size)
            self.SetClientSize(size)
            self.SetMaxClientSize(size)
        else:
            self.SetMaxSize((-1, -1))
            self.SetMinSize(size)
            self.SetSize(size)
            self.SetMaxSize(size)
        display_rect = display[1]
        if DEBUG:
            print("[D]  display_rect:", display_rect)
        display_size = display_rect[2:]
        if DEBUG:
            print("[D]  display_size:", display_size)
        if sys.platform in ("darwin", "win32"):
            titlebar = 0  # size already includes window decorations
        else:
            titlebar = 25  # assume titlebar height of 25px
        measureframe_pos = [
            display_rect[0] + round((display_size[0] - size[0]) * x),
            display_rect[1] + round((display_size[1] - size[1]) * y) - titlebar,
        ]
        measureframe_pos[0] = max(measureframe_pos[0], display_client_rect[0])
        measureframe_pos[1] = max(measureframe_pos[1], display_client_rect[1])
        if DEBUG:
            print("[D]  measureframe_pos:", measureframe_pos)
        setcfg("dimensions.measureframe", ",".join(strlist((x, y, scale))))
        self.SetPosition(measureframe_pos)

    def zoomin_handler(self, event):
        """Handle the zoom in event for the measure frame.

        Args:
            event (wx.Event): The event object containing information about
                the zoom in event.
        """
        if DEBUG:
            print("[D] measureframe_zoomin_handler")
        # We can't use self.get_dimensions() here because if we are near
        # fullscreen, next magnification step will be larger than normal
        display_size = self.get_display()[1][2:]
        default_measureframe_size = float(get_default_size())
        size = floatlist(self.GetSize())
        x, y = None, None
        scale = (float(display_size[0]) / default_measureframe_size) / (
            float(display_size[0]) / size[0]
        ) + 0.125
        self.place_n_zoom(
            x,
            y,
            scale=scale,
        )

    def zoomout_handler(self, event):
        """Handle the zoom out event for the measure frame.

        Args:
            event (wx.Event): The event object containing information about
                the zoom out event.
        """
        if DEBUG:
            print("[D] measureframe_zoomout_handler")
        # We can't use self.get_dimensions() here because if we are
        # fullscreen, scale will be 50, thus changes won't be visible quickly
        display_size = self.get_display()[1][2:]
        default_measureframe_size = float(get_default_size())
        size = floatlist(self.GetSize())
        x, y = None, None
        scale = (float(display_size[0]) / default_measureframe_size) / (
            float(display_size[0]) / size[0]
        ) - 0.125
        self.place_n_zoom(
            x,
            y,
            scale=scale,
        )

    def zoomnormal_handler(self, event):
        """Handle the zoom normal event for the measure frame.

        Args:
            event (wx.Event): The event object containing information about
                the zoom normal event.
        """
        if DEBUG:
            print("[D] measureframe_zoomnormal_handler")
        x, y = None, None
        scale = floatlist(DEFAULTS["dimensions.measureframe"].split(","))[2]
        self.place_n_zoom(x, y, scale=scale)

    def zoommax_handler(self, event):
        """Handle the zoom max event for the measure frame.

        Args:
            event (wx.Event): The event object containing information about
                the zoom max event.
        """
        if DEBUG:
            print("[D] measureframe_zoommax_handler")
        display_client_rect = self.get_display()[2]
        if DEBUG:
            print("[D]  display_client_rect:", display_client_rect)
        display_client_size = display_client_rect[2:]
        if DEBUG:
            print("[D]  display_client_size:", display_client_size)
        size = self.GetSize()
        if DEBUG:
            print(" size:", size)
        if max(size) >= max(display_client_size) - 50:
            dim = getcfg("dimensions.measureframe.unzoomed")
            self.place_n_zoom(*floatlist(dim.split(",")))
        else:
            setcfg("dimensions.measureframe.unzoomed", self.get_dimensions())
            self.place_n_zoom(x=0.5, y=0.5, scale=50.0)

    def center_handler(self, event):
        """Handle the center event for the measure frame.

        Args:
            event (wx.Event): The event object containing information about
                the center event.
        """
        if DEBUG:
            print("[D] measureframe_center_handler")
        x, y = floatlist(DEFAULTS["dimensions.measureframe"].split(","))[:2]
        self.place_n_zoom(x, y)

    def close_handler(self, event):
        """Handle the close event for the measure frame.

        Args:
            event (wx.CloseEvent): The event object containing information
                about the close event.
        """
        if DEBUG:
            print("[D] measureframe_close_handler")
        if self.Parent:
            if self.Parent.worker.is_working():
                self.Parent.worker.abort_subprocess(confirm=True)
                return
            self.Hide()
            self.Parent.Show()
            if hasattr(self.Parent, "restore_measurement_mode"):
                self.Parent.restore_measurement_mode()
            if hasattr(self.Parent, "restore_testchart"):
                self.Parent.restore_testchart()
        else:
            self.Hide()
            writecfg()
            self.Destroy()
            if MeasureFrame.exitcode != 255:
                MeasureFrame.exitcode = 0

    def get_display(self, display_no=None):
        """Return display number, geometry, and client area, handling multi-screen setups.

        Args:
            display_no (int, optional): The display number to get the geometry
                for. If None, the current display is used.

        Returns:
            tuple: A tuple containing the display number, geometry (wx.Rect),
                and client area (wx.Rect).
        """  # noqa: E501
        if wx.Display.GetCount() == 1 and len(self.display_rects) > 1:
            # Separate X screens, TwinView or similar
            display = wx.Display(0)
            geometry = display.Geometry
            union = wx.Rect()
            xy = []
            for rect in self.display_rects:
                if rect[:2] in xy or rect[2:] == geometry[2:]:
                    # Overlapping x y coordinates or screen filling whole
                    # reported geometry, so assume separate X screens
                    union = None
                    break
                xy.append(rect[:2])
                union = union.Union(rect)
            if union == geometry:
                # Assume TwinView or similar where Argyll enumerates 1+n
                # displays but wx only 'sees' one that is the union of them
                framerect = self.Rect
                if display_no is not None:
                    geometry = self.display_rects[display_no]
                else:
                    display_no = 0
                    for i, coord in enumerate(framerect[:2]):
                        if coord < 0:
                            framerect[i] = 0
                        elif coord > geometry[i + 2]:
                            framerect[i] = geometry[i]
                    for i, display_rect in enumerate(self.display_rects):
                        if display_rect.Contains(framerect[:2]):
                            display_no = i
                            geometry = display_rect
                            break
            elif display_no is None:
                # Assume separate X screens
                display_no = 0
            client_rect = wx.Rect(*tuple(geometry)).Intersect(
                display.GetRealClientArea()
            )
        else:
            display_no = wx.Display.GetFromWindow(self)
            display = self.GetDisplay()
            geometry = display.Geometry
            client_rect = display.GetRealClientArea()
        return display_no, geometry, client_rect

    def move_handler(self, event):
        """Handle the move event for the measure frame.

        Args:
            event (wx.MoveEvent): The event object containing information
                about the move event.
        """
        if not self.IsShownOnScreen():
            return
        display_no, geometry, client_area = self.get_display()
        if display_no == self.display_no:
            return
        self.display_no = display_no
        if config.is_virtual_display():
            return
        # Translate from wx display index to Argyll display index
        n = get_argyll_display_number(geometry)
        if n is not None:
            # Save Argyll display index to configuration
            setcfg("display.number", n + 1)

    def focus_handler(self, event=None):
        """Handle the focus event for the measure frame.

        Args:
            event (wx.FocusEvent, optional): The event object containing
                information about the focus event. Defaults to None.
        """
        event.Skip()
        if DEBUG:
            print("SET_FOCUS", event.EventObject.Name)
        if event.EventObject is self and getattr(self, "last_focused", None) not in (
            None,
            self,
        ):
            self.last_focused.SetFocus()
            if DEBUG:
                print(self.last_focused.Name + ".SetFocus()")

    def focus_lost_handler(self, event):
        """Handle the focus lost event for the measure frame.

        Args:
            event (wx.FocusEvent): The event object containing information
                about the focus lost event.
        """
        event.Skip()
        if DEBUG:
            print("KILL_FOCUS", event.EventObject.Name)
        if event.EventObject is not self:
            self.last_focused = event.EventObject
            if DEBUG and self.last_focused:
                print("last_focused", self.last_focused.Name)

    def show_handler(self, e):
        """Handle the show event for the measure frame.

        Args:
            e (wx.ShowEvent): The event object containing information about the
                show event.
        """
        e.Skip()
        if getattr(e, "IsShown", getattr(e, "GetShow", bool))():
            self.measurebutton.SetFocus()

    def show_controls(self, show=True):
        """Show or hide the controls in the measure frame.

        Args:
            show (bool): If True, show the controls; if False, hide them.
        """
        self.panel.Freeze()
        for ctrl in self.panel.Children:
            ctrl.Show(show)
        if show:
            self.panel.SetBitmap(None)
            self.panel.SetCursor(wx.StockCursor(wx.CURSOR_DEFAULT))
        else:
            self.panel.SetCursor(wx.StockCursor(wx.CURSOR_BLANK))
        self.panel.Layout()
        self.panel.Thaw()

    def show_rgb(self, rgb):
        """Show the RGB color in the measure frame.

        The RGB values should be in the range 0.0...1.0.

        Args:
            rgb (tuple): A tuple of three floats representing the RGB color.

        """
        if getcfg("patterngenerator.use_video_levels"):
            minv = 16
            maxv = 235
        else:
            minv = 0
            maxv = 255
        rgb = tuple(minv + v * (maxv - minv) for v in rgb)
        floor = tuple(math.floor(v) for v in rgb)
        ceil = tuple(math.ceil(v) for v in rgb)
        if floor != ceil:
            # Dither using simple ordered pattern
            print(
                f"Dither 8 bit {rgb[0]:.6f} {rgb[1]:.6f} {rgb[2]:.6f} -> "
                f"{floor[0]} {floor[1]} {floor[2]} | {ceil[0]} {ceil[1]} {ceil[2]}"
            )
            img = wx.EmptyImage(*self.ClientSize, clear=False)
            buf = img.GetDataBuffer()
            buflen = len(buf)
            # Intervals in pixels per each R, G and B
            intervals = tuple(
                (buflen / (buflen * (rgb[i] - floor[i])) if rgb[i] - floor[i] else 0)
                for i in range(3)
            )
            print("Intervals {:.6f} {:.6f} {:.6f}".format(*intervals))
            floorbytes = tuple(chr(v) for v in floor)
            ceilbytes = tuple(chr(v) for v in ceil)
            n = 0
            # XXX: Generating the dithered image can take nontrivial amounts
            # of time if the image is large
            ts = time.time()
            for i, _byte in enumerate(buf):
                m = intervals[i % 3]
                color = ceilbytes if m and n % m < 1 else floorbytes
                buf[i] = color[i % 3]
                if i % 3 == 2:
                    n += 1
            print("Generating dithered image took %.3fs" % (time.time() - ts))
            bmp = img.ConvertToBitmap()
        else:
            # Exact
            print("Exact 8 bit {:.6f} {:.6f} {:.6f}".format(*rgb))
            bmp = wx.EmptyBitmapRGBA(*tuple(self.ClientSize) + floor, alpha=255)
        self.panel.SetBitmap(bmp)
        self.panel.Refresh()
        if self.Parent:
            self.Parent.worker._patterngenerator_wait = False

    def get_dimensions(self):
        """Calculate and return the relative dimensions from the pixel values.

        Returns x, y and scale in Argyll coordinates (0.0...1.0).
        """
        if DEBUG:
            print("[D] measureframe.get_dimensions")
        display = self.get_display()
        display_rect = display[1]
        display_size = display_rect[2:]
        display_client_rect = display[2]
        display_client_size = display_client_rect[2:]
        if DEBUG:
            print("[D]  display_size:", display_size)
            print("[D]  display_client_size:", display_client_size)
        default_measureframe_size = float(get_default_size())
        if DEBUG:
            print("[D]  default_measureframe_size:", default_measureframe_size)
        measureframe_pos = floatlist(self.GetScreenPosition())
        measureframe_pos[0] -= display_rect[0]
        measureframe_pos[1] -= display_rect[1]
        if DEBUG:
            print("[D]  measureframe_pos:", measureframe_pos)
        size = floatlist(self.GetSize())
        if DEBUG:
            print(" size:", size)
        # On Windows with certain wxPython versions, wx.Display.Geometry returns
        # DIP (logical) pixels while GetSize()/GetScreenPosition() return physical
        # pixels. This causes the computed x, y fractions to be wrong (e.g. the
        # window at the display center maps to x=1.0 instead of x=0.5 at 175% DPI).
        # Detect this by comparing GetSize() against the size we passed to SetSize()
        # during place_n_zoom() - both should be in the same coordinate system as
        # Display.Geometry. If they differ, apply the ratio as a correction factor.
        last_set_size = getattr(self, "_last_set_size", None)
        if last_set_size and size[0] and abs(size[0] - last_set_size) > 1:
            coord_scale = last_set_size / size[0]
            measureframe_pos = [p * coord_scale for p in measureframe_pos]
            size = [s * coord_scale for s in size]
            if DEBUG:
                print(
                    f"[D]  coord_scale {coord_scale:.4f} applied "
                    f"(last_set_size={last_set_size}, GetSize={size[0] / coord_scale:.0f})"
                )
        if max(size) >= max(display_client_size) - 50:
            # Fullscreen?
            scale = 50.0  # Argyll max is 50
            measureframe_pos = [0.5, 0.5]
        else:
            scale = (float(display_size[0]) / default_measureframe_size) / (
                float(display_size[0]) / size[0]
            )
            if DEBUG:
                print("[D]  scale:", scale)
                print("[D]  scale_adjustment_factor:", SCALE_ADJUSTMENT_FACTOR)
            scale *= float(SCALE_ADJUSTMENT_FACTOR)
            if size[0] >= display_client_size[0]:
                measureframe_pos[0] = 0.5
            elif measureframe_pos[0] != 0:
                measureframe_pos[0] = min(
                    measureframe_pos[0], display_size[0] - size[0]
                )
                measureframe_pos[0] = 1.0 / (
                    (float(display_size[0]) - size[0]) / (measureframe_pos[0])
                )
            if size[1] >= display_client_size[1]:
                measureframe_pos[1] = 0.5
            elif measureframe_pos[1] != 0:
                measureframe_pos[1] = min(
                    measureframe_pos[1], display_size[1] - size[1]
                )
                if sys.platform in ("darwin", "win32"):
                    titlebar = 0  # size already includes window decorations
                else:
                    titlebar = 25  # assume titlebar height of 25px
                measureframe_pos[1] = 1.0 / (
                    (float(display_size[1] - size[1]))
                    / (float(measureframe_pos[1] + titlebar))
                )
        if DEBUG:
            print("[D]  scale:", scale)
        if DEBUG:
            print("[D]  measureframe_pos:", measureframe_pos)
        measureframe_dimensions = ",".join(
            str(max(0, n)) for n in [*measureframe_pos, scale]
        )
        if DEBUG:
            print("[D]  measureframe_dimensions:", measureframe_dimensions)
        return measureframe_dimensions


def main():
    """Main function to run the wxPython application."""
    config.initcfg()
    lang.init()
    app = BaseApp(0)
    app.TopWindow = MeasureFrame()
    app.TopWindow.Show()
    if "--test-dither" in sys.argv[1:]:
        print("Testing thourhg the `--test-dither` argument is not supported.")
    app.MainLoop()


if __name__ == "__main__":
    main()
    sys.exit(MeasureFrame.exitcode)
