# -*- coding: utf-8 -*-
"""EDMC preferences library."""
from __future__ import annotations

import contextlib
import logging
from os.path import expandvars
from pathlib import Path
import subprocess
import sys
import tkinter as tk
import warnings
from os import system
from tkinter import colorchooser as tkColorChooser  # type: ignore # noqa: N812
from tkinter import ttk
from types import TracebackType
from typing import Any, Callable, Optional, Type
import myNotebook as nb  # noqa: N813
import plug
from config import appversion_nobuild, config
from EDMCLogging import edmclogger, get_main_logger
from hotkey import hotkeymgr
from l10n import translations as tr
from monitor import monitor
from theme import theme
from ttkHyperlinkLabel import HyperlinkLabel
logger = get_main_logger()


# TODO: Decouple this from platform as far as possible

###########################################################################
# Versioned preferences, so we know whether to set an 'on' default on
# 'new' preferences, or not.
###########################################################################

# May be imported by plugins

# DEPRECATED: Migrate to open_log_folder. Will remove in 6.0 or later.
def help_open_log_folder() -> None:
    """Open the folder logs are stored in."""
    warnings.warn('prefs.help_open_log_folder is deprecated, use open_log_folder instead. '
                  'This function will be removed in 6.0 or later', DeprecationWarning, stacklevel=2)
    open_folder(Path(config.app_dir_path / 'logs'))


def open_folder(file: Path) -> None:
    """Open the given file in the OS file explorer."""
    if sys.platform.startswith('win'):
        # On Windows, use the "start" command to open the folder
        system(f'start "" "{file}"')
    elif sys.platform.startswith('linux'):
        # On Linux, use the "xdg-open" command to open the folder
        system(f'xdg-open "{file}"')


def help_open_system_profiler(parent) -> None:
    """Open the EDMC System Profiler."""
    profiler_path = config.respath_path
    try:
        if getattr(sys, 'frozen', False):
            profiler_path /= 'EDMCSystemProfiler.exe'
            subprocess.run(profiler_path, check=True)
        else:
            subprocess.run(['python', "EDMCSystemProfiler.py"], shell=True, check=True)
    except Exception as err:
        parent.status["text"] = tr.tl("Error in System Profiler")  # LANG: Catch & Record Profiler Errors
        logger.exception(err)


class PrefsVersion:
    """
    PrefsVersion contains versioned preferences.

    It allows new defaults to be set as they are added if they are found to be missing
    """

    versions = {
        '0.0.0.0': 1,
        '1.0.0.0': 2,
        '3.4.6.0': 3,
        '3.5.1.0': 4,
        # Only add new versions that add new Preferences
        # Should always match the last specific version, but only increment after you've added the new version.
        # Guess at it if anticipating a new version.
        'current': 4,
    }

    def __init__(self):
        return

    def stringToSerial(self, versionStr: str) -> int:  # noqa: N802, N803 # used in plugins
        """
        Convert a version string into a preferences version serial number.

        If the version string isn't known returns the 'current' (latest) serial number.

        :param versionStr:
        :return int:
        """
        if versionStr in self.versions:
            return self.versions[versionStr]

        return self.versions['current']

    def shouldSetDefaults(self, addedAfter: str, oldTest: bool = True) -> bool:  # noqa: N802,N803 # used in plugins
        """
        Whether or not defaults should be set if they were added after the specified version.

        :param addedAfter: The version after which these settings were added
        :param oldTest: Default, if we have no current settings version, defaults to True
        :raises ValueError: on serial number after the current latest
        :return: bool indicating the answer
        """
        # config.get('PrefsVersion') is the version preferences we last saved for
        pv = config.get_int('PrefsVersion')
        # If no PrefsVersion yet exists then return oldTest
        if not pv:
            return oldTest

        # Convert addedAfter to a version serial number
        if addedAfter not in self.versions:
            # Assume it was added at the start
            aa = 1

        else:
            aa = self.versions[addedAfter]
            # Sanity check, if something was added after then current should be greater
            if aa >= self.versions['current']:
                raise ValueError(
                    'ERROR: Call to prefs.py:PrefsVersion.shouldSetDefaults() with '
                    '"addedAfter" >= current latest in "versions" table.'
                    '  You probably need to increase "current" serial number.'
                )

        # If this preference was added after the saved PrefsVersion we should set defaults
        if aa >= pv:
            return True

        return False


prefsVersion = PrefsVersion()  # noqa: N816 # Cannot rename as used in plugins


class AutoInc(contextlib.AbstractContextManager):
    """
    Autoinc is a self incrementing int.

    As a context manager, it increments on enter, and does nothing on exit.
    """

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

    def get(self, increment=True) -> int:
        """
        Get the current integer, optionally incrementing it.

        :param increment: whether or not to increment the stored value, defaults to True
        :return: the current value
        """
        current = self.current
        if increment:
            self.current += self.step

        return current

    def __enter__(self):
        """
        Increments once, alias to .get.

        :return: the current value
        """
        return self.get()

    def __exit__(
        self,
        exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType]
    ) -> Optional[bool]:
        """Do nothing."""
        return None


if sys.platform == 'win32':
    import ctypes
    import winreg
    from ctypes.wintypes import LPCWSTR, LPWSTR, MAX_PATH, POINT, RECT, SIZE, UINT, BOOL
    import win32gui
    import win32api
    is_wine = False
    try:
        WINE_REGISTRY_KEY = r'HKEY_LOCAL_MACHINE\Software\Wine'
        reg = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE)
        winreg.OpenKey(reg, WINE_REGISTRY_KEY)
        is_wine = True

    except OSError:  # Assumed to be 'path not found', i.e. not-wine
        pass

    CalculatePopupWindowPosition = None
    if not is_wine:
        try:
            CalculatePopupWindowPosition = ctypes.windll.user32.CalculatePopupWindowPosition
            CalculatePopupWindowPosition.argtypes = [POINT, SIZE, UINT, RECT, RECT]
            CalculatePopupWindowPosition.restype = BOOL

        except AttributeError as e:
            logger.error(
                'win32 and not is_wine, but ctypes.windll.user32.CalculatePopupWindowPosition invalid',
                exc_info=e
            )

        else:
            CalculatePopupWindowPosition.argtypes = [
                ctypes.POINTER(POINT),
                ctypes.POINTER(SIZE),
                UINT,
                ctypes.POINTER(RECT),
                ctypes.POINTER(RECT)
            ]

    SHGetLocalizedName = ctypes.windll.shell32.SHGetLocalizedName
    SHGetLocalizedName.argtypes = [LPCWSTR, LPWSTR, UINT, ctypes.POINTER(ctypes.c_int)]


class PreferencesDialog(tk.Toplevel):
    """The EDMC preferences dialog."""

    def __init__(self, parent: tk.Tk, callback: Optional[Callable]):
        super().__init__(parent)

        self.parent = parent
        self.callback = callback
        self.req_restart = False
        # LANG: File > Settings (macOS)
        self.title(tr.tl('Settings'))

        if parent.winfo_viewable():
            self.transient(parent)

        # Position over parent
        self.geometry(f'+{parent.winfo_rootx()}+{parent.winfo_rooty()}')

        # Remove decoration
        if sys.platform == 'win32':
            self.attributes('-toolwindow', tk.TRUE)

        # Allow the window to be resizable
        self.resizable(tk.TRUE, tk.TRUE)

        self.cmdr: str | bool | None = False  # Note if Cmdr changes in the Journal
        self.is_beta: bool = False  # Note if Beta status changes in the Journal
        self.cmdrchanged_alarm: Optional[str] = None  # This stores an ID that can be used to cancel a scheduled call

        # Set up the main frame
        frame = ttk.Frame(self)
        frame.grid(sticky=tk.NSEW)
        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)
        frame.columnconfigure(0, weight=1)
        frame.rowconfigure(0, weight=1)
        frame.rowconfigure(1, weight=0)

        notebook: nb.Notebook = nb.Notebook(frame)
        notebook.bind('<<NotebookTabChanged>>', self.tabchanged)  # Recompute on tab change

        self.PADX = 10
        self.BUTTONX = 12  # indent Checkbuttons and Radiobuttons
        self.LISTX = 25  # indent listed items
        self.PADY = 1  # close spacing
        self.BOXY = 2  # box spacing
        self.SEPY = 10  # separator line spacing

        # Set up different tabs
        self.__setup_config_tab(notebook)
        self.__setup_appearance_tab(notebook)
        self.__setup_output_tab(notebook)
        self.__setup_privacy_tab(notebook)
        self.__setup_plugin_tab(notebook)
        self.__setup_plugin_tabs(notebook)

        # Set up the button frame
        buttonframe = ttk.Frame(frame)
        buttonframe.grid(padx=self.PADX, pady=self.PADX, sticky=tk.NSEW)
        buttonframe.columnconfigure(0, weight=1)
        ttk.Label(buttonframe).grid(row=0, column=0)  # spacer
        # LANG: 'OK' button on Settings/Preferences window
        button = ttk.Button(buttonframe, text=tr.tl('OK'), command=self.apply)
        button.grid(row=0, column=1, sticky=tk.E)
        button.bind("<Return>", lambda event: self.apply())
        self.protocol("WM_DELETE_WINDOW", self._destroy)

        # FIXME: Why are these being called when *creating* the Settings window?
        # Selectively disable buttons depending on output settings
        self.cmdrchanged()
        self.themevarchanged()

        # disable hotkey for the duration
        hotkeymgr.unregister()

        # wait for window to appear on screen before calling grab_set
        self.parent.update_idletasks()
        self.parent.wm_attributes('-topmost', 0)  # needed for dialog to appear on top of parent on Linux
        self.wait_visibility()
        self.grab_set()

        # Ensure fully on-screen
        if sys.platform == 'win32' and CalculatePopupWindowPosition:
            position = RECT()
            win32gui.GetWindowRect(win32gui.GetParent(self.winfo_id()))
            if CalculatePopupWindowPosition(
                POINT(parent.winfo_rootx(), parent.winfo_rooty()),
                SIZE(position.right - position.left, position.bottom - position.top),  # type: ignore
                0x10000, None, position
            ):
                self.geometry(f"+{position.left}+{position.top}")

        # Set Log Directory
        self.logfile_loc = Path(config.app_dir_path / 'logs')

        # Set minimum size to prevent content cut-off
        self.update_idletasks()  # Update "requested size" from geometry manager
        min_width = self.winfo_reqwidth()
        min_height = self.winfo_reqheight()
        self.wm_minsize(min_width, min_height)

    def __setup_output_tab(self, root_notebook: ttk.Notebook) -> None:
        output_frame = nb.Frame(root_notebook)
        output_frame.columnconfigure(0, weight=1)

        if prefsVersion.shouldSetDefaults('0.0.0.0', not bool(config.get_int('output'))):
            output = config.OUT_SHIP  # default settings

        else:
            output = config.get_int('output')

        row = AutoInc(start=0)

        # LANG: Settings > Output - choosing what data to save to files
        self.out_label = nb.Label(output_frame, text=tr.tl('Please choose what data to save'))
        self.out_label.grid(columnspan=2, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get())

        self.out_csv = tk.IntVar(value=1 if (output & config.OUT_MKT_CSV) else 0)
        self.out_csv_button = nb.Checkbutton(
            output_frame,
            text=tr.tl('Market data in CSV format file'),  # LANG: Settings > Output option
            variable=self.out_csv,
            command=self.outvarchanged
        )
        self.out_csv_button.grid(columnspan=2, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get())

        self.out_td = tk.IntVar(value=1 if (output & config.OUT_MKT_TD) else 0)
        self.out_td_button = nb.Checkbutton(
            output_frame,
            text=tr.tl('Market data in Trade Dangerous format file'),  # LANG: Settings > Output option
            variable=self.out_td,
            command=self.outvarchanged
        )
        self.out_td_button.grid(columnspan=2, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get())
        self.out_ship = tk.IntVar(value=1 if (output & config.OUT_SHIP) else 0)

        # Output setting
        self.out_ship_button = nb.Checkbutton(
            output_frame,
            text=tr.tl('Ship loadout'),  # LANG: Settings > Output option
            variable=self.out_ship,
            command=self.outvarchanged
        )
        self.out_ship_button.grid(columnspan=2, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get())
        self.out_auto = tk.IntVar(value=0 if output & config.OUT_MKT_MANUAL else 1)  # inverted

        # Output setting
        self.out_auto_button = nb.Checkbutton(
            output_frame,
            text=tr.tl('Automatically update on docking'),  # LANG: Settings > Output option
            variable=self.out_auto,
            command=self.outvarchanged
        )
        self.out_auto_button.grid(columnspan=2, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get())

        self.outdir = tk.StringVar()
        self.outdir.set(str(config.get_str('outdir')))
        # LANG: Settings > Output - Label for "where files are located"
        self.outdir_label = nb.Label(output_frame, text=tr.tl('File location')+':')  # Section heading in settings
        # Type ignored due to incorrect type annotation. a 2 tuple does padding for each side
        self.outdir_label.grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get())  # type: ignore

        self.outdir_entry = ttk.Entry(output_frame, takefocus=False)
        self.outdir_entry.grid(columnspan=2, padx=self.PADX, pady=self.BOXY, sticky=tk.EW, row=row.get())

        text = tr.tl('Browse...')  # LANG: NOT-macOS Settings - files location selection button

        self.outbutton = ttk.Button(
            output_frame,
            text=text,
            # Technically this is different from the label in Settings > Output, as *this* is used
            # as the title of the popup folder selection window.
            # LANG: Settings > Output - Label for "where files are located"
            command=lambda: self.filebrowse(tr.tl('File location'), self.outdir)
        )
        self.outbutton.grid(column=1, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=row.get())

        # LANG: Label for 'Output' Settings/Preferences tab
        root_notebook.add(output_frame, text=tr.tl('Output'))  # Tab heading in settings

    def __setup_plugin_tabs(self, notebook: ttk.Notebook) -> None:
        for plugin in plug.PLUGINS:
            plugin_frame = plugin.get_prefs(notebook, monitor.cmdr, monitor.is_beta)
            if plugin_frame:
                notebook.add(plugin_frame, text=plugin.name)

    def __setup_config_tab(self, notebook: ttk.Notebook) -> None:  # noqa: CCR001
        config_frame = nb.Frame(notebook)
        config_frame.columnconfigure(1, weight=1)
        row = AutoInc(start=0)

        self.logdir = tk.StringVar()
        default = config.default_journal_dir if config.default_journal_dir_path is not None else ''
        logdir = config.get_str('journaldir')
        if logdir is None or logdir == '':
            logdir = default

        self.logdir.set(logdir)
        self.logdir_entry = ttk.Entry(config_frame, takefocus=False)

        # Location of the Journal files
        nb.Label(
            config_frame,
            # LANG: Settings > Configuration - Label for Journal files location
            text=tr.tl('E:D journal file location')+':'
        ).grid(columnspan=4, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get())

        self.logdir_entry.grid(columnspan=4, padx=self.PADX, pady=self.BOXY, sticky=tk.EW, row=row.get())

        text = tr.tl('Browse...')  # LANG: NOT-macOS Setting - files location selection button

        with row as cur_row:
            self.logbutton = ttk.Button(
                config_frame,
                text=text,
                # LANG: Settings > Configuration - Label for Journal files location
                command=lambda: self.filebrowse(tr.tl('E:D journal file location'), self.logdir)
            )
            self.logbutton.grid(column=3, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row)

            if config.default_journal_dir_path:
                # Appearance theme and language setting
                ttk.Button(
                    config_frame,
                    # LANG: Settings > Configuration - Label on 'reset journal files location to default' button
                    text=tr.tl('Default'),
                    command=self.logdir_reset,
                    state=tk.NORMAL if config.get_str('journaldir') else tk.DISABLED
                ).grid(column=2, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row)

        # CAPI settings
        self.capi_fleetcarrier = tk.BooleanVar(value=config.get_bool('capi_fleetcarrier'))

        ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid(
                columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
            )

        nb.Label(
                config_frame,
                text=tr.tl('CAPI Settings')  # LANG: Settings > Configuration - Label for CAPI section
            ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get())

        nb.Checkbutton(
                config_frame,
                # LANG: Configuration - Enable or disable the Fleet Carrier CAPI calls
                text=tr.tl('Enable Fleet Carrier CAPI Queries'),
                variable=self.capi_fleetcarrier
            ).grid(columnspan=4, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get())

        if sys.platform == 'win32':
            ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid(
                columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
            )

            self.hotkey_code = config.get_int('hotkey_code')
            self.hotkey_mods = config.get_int('hotkey_mods')
            self.hotkey_only = tk.IntVar(value=not config.get_int('hotkey_always'))
            self.hotkey_play = tk.IntVar(value=not config.get_int('hotkey_mute'))
            with row as cur_row:
                nb.Label(
                    config_frame,
                    text=tr.tl('Hotkey')  # LANG: Hotkey/Shortcut settings prompt on Windows
                ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row)

                self.hotkey_text = ttk.Entry(config_frame, width=30, justify=tk.CENTER)
                self.hotkey_text.insert(
                    0,
                    # No hotkey/shortcut currently defined
                    # TODO: display Only shows up on windows
                    # LANG: No hotkey/shortcut set
                    hotkeymgr.display(self.hotkey_code, self.hotkey_mods) if self.hotkey_code else tr.tl('None')
                )

                self.hotkey_text.bind('<FocusIn>', self.hotkeystart)
                self.hotkey_text.bind('<FocusOut>', self.hotkeyend)
                self.hotkey_text.grid(column=1, columnspan=2, pady=self.BOXY, sticky=tk.W, row=cur_row)

                # Hotkey/Shortcut setting
                self.hotkey_only_btn = nb.Checkbutton(
                    config_frame,
                    # LANG: Configuration - Act on hotkey only when ED is in foreground
                    text=tr.tl('Only when Elite: Dangerous is the active app'),
                    variable=self.hotkey_only,
                    state=tk.NORMAL if self.hotkey_code else tk.DISABLED
                )

                self.hotkey_only_btn.grid(columnspan=4, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get())

                # Hotkey/Shortcut setting
                self.hotkey_play_btn = nb.Checkbutton(
                    config_frame,
                    # LANG: Configuration - play sound when hotkey used
                    text=tr.tl('Play sound'),
                    variable=self.hotkey_play,
                    state=tk.NORMAL if self.hotkey_code else tk.DISABLED
                )

                self.hotkey_play_btn.grid(columnspan=4, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get())

        # Options to select the Update Path and Disable Automatic Checks For Updates whilst in-game
        ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid(
            columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
        )

        with row as curr_row:
            nb.Label(config_frame, text=tr.tl('Update Track')).grid(  # LANG: Select the Update Track (Beta, Stable)
                padx=self.PADX, pady=self.PADY, sticky=tk.W, row=curr_row
            )
            self.curr_update_track = "Beta" if config.get_bool('beta_optin') else "Stable"
            self.update_paths = tk.StringVar(value=self.curr_update_track)

            update_paths = [
                tr.tl("Stable"),  # LANG: Stable Version of EDMC
                tr.tl("Beta")  # LANG: Beta Version of EDMC
            ]
            self.update_track = nb.OptionMenu(
                config_frame, self.update_paths, self.update_paths.get(), *update_paths
            )

            self.update_track.configure(width=15)
            self.update_track.grid(column=1, pady=self.BOXY, padx=self.PADX, sticky=tk.W, row=curr_row)

        self.disable_autoappupdatecheckingame = tk.IntVar(value=config.get_int('disable_autoappupdatecheckingame'))
        self.disable_autoappupdatecheckingame_btn = nb.Checkbutton(
            config_frame,
            # LANG: Configuration - disable checks for app updates when in-game
            text=tr.tl('Disable Automatic Application Updates Check when in-game'),
            variable=self.disable_autoappupdatecheckingame,
            command=self.disable_autoappupdatecheckingame_changed
        )

        self.disable_autoappupdatecheckingame_btn.grid(
            columnspan=4, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get()
        )

        ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid(
            columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
        )

        # Settings prompt for preferred ship loadout, system and station info websites
        # LANG: Label for preferred shipyard, system and station 'providers'
        nb.Label(config_frame, text=tr.tl('Preferred websites')).grid(
            columnspan=4, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get()
        )

        with row as cur_row:
            shipyard_provider = config.get_str('shipyard_provider')
            self.shipyard_provider = tk.StringVar(
                value=str(shipyard_provider if shipyard_provider in plug.provides('shipyard_url') else 'EDSY')
            )
            # Setting to decide which ship outfitting website to link to - either E:D Shipyard or Coriolis
            # LANG: Label for Shipyard provider selection
            nb.Label(config_frame, text=tr.tl('Shipyard')).grid(
                padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row
            )
            self.shipyard_button = nb.OptionMenu(
                config_frame, self.shipyard_provider, self.shipyard_provider.get(), *plug.provides('shipyard_url')
            )

            self.shipyard_button.configure(width=15)
            self.shipyard_button.grid(column=1, pady=self.BOXY, sticky=tk.W, row=cur_row)
            # Option for alternate URL opening
            self.alt_shipyard_open = tk.IntVar(value=config.get_int('use_alt_shipyard_open'))
            self.alt_shipyard_open_btn = nb.Checkbutton(
                config_frame,
                # LANG: Label for checkbox to utilise alternative Coriolis URL method
                text=tr.tl('Use alternate URL method'),
                variable=self.alt_shipyard_open,
                command=self.alt_shipyard_open_changed,
            )

            self.alt_shipyard_open_btn.grid(column=2, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row)

        with row as cur_row:
            system_provider = config.get_str('system_provider')
            self.system_provider = tk.StringVar(
                value=str(system_provider if system_provider in plug.provides('system_url') else 'EDSM')
            )

            # LANG: Configuration - Label for selection of 'System' provider website
            nb.Label(config_frame, text=tr.tl('System')).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row)
            self.system_button = nb.OptionMenu(
                config_frame,
                self.system_provider,
                self.system_provider.get(),
                *plug.provides('system_url')
            )

            self.system_button.configure(width=15)
            self.system_button.grid(column=1, pady=self.BOXY, sticky=tk.W, row=cur_row)

        with row as cur_row:
            station_provider = config.get_str('station_provider')
            self.station_provider = tk.StringVar(
                value=str(station_provider if station_provider in plug.provides('station_url') else 'EDSM')
            )

            # LANG: Configuration - Label for selection of 'Station' provider website
            nb.Label(config_frame, text=tr.tl('Station')).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row)
            self.station_button = nb.OptionMenu(
                config_frame,
                self.station_provider,
                self.station_provider.get(),
                *plug.provides('station_url')
            )

            self.station_button.configure(width=15)
            self.station_button.grid(column=1, pady=self.BOXY, sticky=tk.W, row=cur_row)

        # Set loglevel
        ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid(
            columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
        )

        with row as cur_row:
            # Set the current loglevel
            nb.Label(
                config_frame,
                # LANG: Configuration - Label for selection of Log Level
                text=tr.tl('Log Level')
            ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row)

            current_loglevel = config.get_str('loglevel')
            if not current_loglevel:
                current_loglevel = logging.getLevelName(logging.INFO)

            self.select_loglevel = tk.StringVar(value=str(current_loglevel))
            loglevels = list(
                map(logging.getLevelName, (
                    logging.CRITICAL, logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG
                ))
            )

            self.loglevel_dropdown = nb.OptionMenu(
                config_frame,
                self.select_loglevel,
                self.select_loglevel.get(),
                *loglevels
            )

            self.loglevel_dropdown.configure(width=15)
            self.loglevel_dropdown.grid(column=1, pady=self.BOXY, sticky=tk.W, row=cur_row)

            ttk.Button(
                config_frame,
                # LANG: Label on button used to open a filesystem folder
                text=tr.tl('Open Log Folder'),  # Button that opens a folder in Explorer/Finder
                command=lambda: open_folder(self.logfile_loc)
            ).grid(column=2, padx=self.PADX, pady=0, sticky=tk.NSEW, row=cur_row)

        # Big spacer
        nb.Label(config_frame).grid(sticky=tk.W, row=row.get())

        # LANG: Label for 'Configuration' tab in Settings
        notebook.add(config_frame, text=tr.tl('Configuration'))

    def __setup_privacy_tab(self, notebook: ttk.Notebook) -> None:
        privacy_frame = nb.Frame(notebook)
        self.hide_multicrew_captain = tk.BooleanVar(value=config.get_bool('hide_multicrew_captain', default=False))
        self.hide_private_group = tk.BooleanVar(value=config.get_bool('hide_private_group', default=False))
        row = AutoInc(start=0)

        # LANG: UI elements privacy section header in privacy tab of preferences
        nb.Label(privacy_frame, text=tr.tl('Main UI privacy options')).grid(
            row=row.get(), column=0, sticky=tk.W, padx=self.PADX, pady=self.PADY
        )

        nb.Checkbutton(
            # LANG: Hide private group owner name from UI checkbox
            privacy_frame, text=tr.tl('Hide private group name in UI'),
            variable=self.hide_private_group
        ).grid(row=row.get(), column=0, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W)
        nb.Checkbutton(
            # LANG: Hide multicrew captain name from main UI checkbox
            privacy_frame, text=tr.tl('Hide multi-crew captain name'),
            variable=self.hide_multicrew_captain
        ).grid(row=row.get(), column=0, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W)

        notebook.add(privacy_frame, text=tr.tl('Privacy'))  # LANG: Preferences privacy tab title

    def __setup_appearance_tab(self, notebook: ttk.Notebook) -> None:
        self.languages = tr.available_names()
        # Appearance theme and language setting
        # LANG: The system default language choice in Settings > Appearance
        self.lang = tk.StringVar(value=self.languages.get(config.get_str('language'), tr.tl('Default')))
        self.always_ontop = tk.BooleanVar(value=bool(config.get_int('always_ontop')))
        self.minimize_system_tray = tk.BooleanVar(value=config.get_bool('minimize_system_tray'))
        self.theme = tk.IntVar(value=config.get_int('theme'))
        self.theme_colors = [config.get_str('dark_text'), config.get_str('dark_highlight')]
        self.theme_prompts = [
            # LANG: Label for Settings > Appeareance > selection of 'normal' text colour
            tr.tl('Normal text'),		# Dark theme color setting
            # LANG: Label for Settings > Appeareance > selection of 'highlightes' text colour
            tr.tl('Highlighted text'),  # Dark theme color setting
        ]

        row = AutoInc(start=0)

        appearance_frame = nb.Frame(notebook)
        appearance_frame.columnconfigure(2, weight=1)
        with row as cur_row:
            # LANG: Appearance - Label for selection of application display language
            nb.Label(appearance_frame, text=tr.tl('Language')).grid(
                padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row
            )
            self.lang_button = nb.OptionMenu(appearance_frame, self.lang, self.lang.get(), *self.languages.values())
            self.lang_button.grid(column=1, columnspan=2, padx=0, pady=self.BOXY, sticky=tk.W, row=cur_row)

        ttk.Separator(appearance_frame, orient=tk.HORIZONTAL).grid(
            columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
        )

        # Appearance setting
        # LANG: Label for Settings > Appearance > Theme selection
        nb.Label(appearance_frame, text=tr.tl('Theme')).grid(
            columnspan=3, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get()
        )

        # Appearance theme and language setting
        nb.Radiobutton(
            # LANG: Label for 'Default' theme radio button
            appearance_frame, text=tr.tl('Default'), variable=self.theme,
            value=theme.THEME_DEFAULT, command=self.themevarchanged
        ).grid(columnspan=3, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get())

        # Appearance theme setting
        nb.Radiobutton(
            # LANG: Label for 'Dark' theme radio button
            appearance_frame, text=tr.tl('Dark'), variable=self.theme,
            value=theme.THEME_DARK, command=self.themevarchanged
        ).grid(columnspan=3, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get())

        if sys.platform == 'win32':
            nb.Radiobutton(
                appearance_frame,
                # LANG: Label for 'Transparent' theme radio button
                text=tr.tl('Transparent'),  # Appearance theme setting
                variable=self.theme,
                value=theme.THEME_TRANSPARENT,
                command=self.themevarchanged
            ).grid(columnspan=3, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get())

        with row as cur_row:
            self.theme_label_0 = nb.Label(appearance_frame, text=self.theme_prompts[0])
            self.theme_label_0.grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row)

            # Main window
            self.theme_button_0 = tk.Button(
                appearance_frame,
                # LANG: Appearance - Example 'Normal' text
                text=tr.tl('Station'),
                background='grey4',
                command=lambda: self.themecolorbrowse(0)
            )

            self.theme_button_0.grid(column=1, padx=0, pady=self.BOXY, sticky=tk.NSEW, row=cur_row)

        with row as cur_row:
            self.theme_label_1 = nb.Label(appearance_frame, text=self.theme_prompts[1])
            self.theme_label_1.grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row)
            self.theme_button_1 = tk.Button(
                appearance_frame,
                text='  Hutton Orbital  ',  # Do not translate
                background='grey4',
                command=lambda: self.themecolorbrowse(1)
            )

            self.theme_button_1.grid(column=1, padx=0, pady=self.BOXY, sticky=tk.NSEW, row=cur_row)

        # UI Scaling
        """
        The provided UI Scale setting is a percentage value relative to the
        tk-scaling setting on startup.

        So, if at startup we find tk-scaling is 1.33 and have a user setting
        of 200 we'll end up setting 2.66 as the tk-scaling value.
        """
        ttk.Separator(appearance_frame, orient=tk.HORIZONTAL).grid(
            columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
        )
        with row as cur_row:
            # LANG: Appearance - Label for selection of UI scaling
            nb.Label(appearance_frame, text=tr.tl('UI Scale Percentage')).grid(
                padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row
            )

            self.ui_scale = tk.IntVar()
            self.ui_scale.set(config.get_int('ui_scale'))
            self.uiscale_bar = tk.Scale(
                appearance_frame,
                variable=self.ui_scale,  # type: ignore # TODO: intvar, but annotated as DoubleVar
                orient=tk.HORIZONTAL,
                length=300 * (float(theme.startup_ui_scale) / 100.0 * theme.default_ui_scale),  # type: ignore # runtime
                from_=0,
                to=400,
                tickinterval=50,
                resolution=10,
            )

            self.uiscale_bar.grid(column=1, padx=0, pady=self.BOXY, sticky=tk.W, row=cur_row)
            self.ui_scaling_defaultis = nb.Label(
                appearance_frame,
                # LANG: Appearance - Help/hint text for UI scaling selection
                text=tr.tl('100 means Default{CR}Restart Required for{CR}changes to take effect!')
            ).grid(column=3, padx=self.PADX, pady=self.PADY, sticky=tk.E, row=cur_row)

        # Transparency slider
        ttk.Separator(appearance_frame, orient=tk.HORIZONTAL).grid(
            columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
        )

        with row as cur_row:
            # LANG: Appearance - Label for selection of main window transparency
            nb.Label(appearance_frame, text=tr.tl("Main window transparency")).grid(
                padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row
            )
            self.transparency = tk.IntVar()
            self.transparency.set(config.get_int('ui_transparency') or 100)  # Default to 100 for users
            self.transparency_bar = tk.Scale(
                appearance_frame,
                variable=self.transparency,  # type: ignore # Its accepted as an intvar
                orient=tk.HORIZONTAL,
                length=300 * (float(theme.startup_ui_scale) / 100.0 * theme.default_ui_scale),  # type: ignore # runtime
                from_=100,
                to=5,
                tickinterval=10,
                resolution=5,
                command=lambda _: self.parent.wm_attributes("-alpha", self.transparency.get() / 100)
            )

            nb.Label(
                appearance_frame,
                # LANG: Appearance - Help/hint text for Main window transparency selection
                text=tr.tl(
                    "100 means fully opaque.{CR}"
                    "Window is updated in real time"
                ).format(CR='\n')
            ).grid(
                column=3,
                padx=self.PADX,
                pady=self.PADY,
                sticky=tk.E,
                row=cur_row
            )

            self.transparency_bar.grid(column=1, padx=0, pady=self.BOXY, sticky=tk.W, row=cur_row)

        # Always on top
        ttk.Separator(appearance_frame, orient=tk.HORIZONTAL).grid(
            columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
        )

        self.ontop_button = nb.Checkbutton(
            appearance_frame,
            # LANG: Appearance - Label for checkbox to select if application always on top
            text=tr.tl('Always on top'),
            variable=self.always_ontop,
            command=self.themevarchanged
        )
        self.ontop_button.grid(
            columnspan=3, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get()
        )  # Appearance setting

        if sys.platform == 'win32':
            nb.Checkbutton(
                appearance_frame,
                # LANG: Appearance option for Windows "minimize to system tray"
                text=tr.tl('Minimize to system tray'),
                variable=self.minimize_system_tray,
                command=self.themevarchanged
            ).grid(columnspan=3, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=row.get())  # Appearance setting

        nb.Label(appearance_frame).grid(sticky=tk.W)  # big spacer

        # LANG: Label for Settings > Appearance tab
        notebook.add(appearance_frame, text=tr.tl('Appearance'))  # Tab heading in settings

    def __setup_plugin_tab(self, notebook: ttk.Notebook) -> None:  # noqa: CCR001
        # Plugin settings and info
        plugins_frame = nb.Frame(notebook)
        plugins_frame.columnconfigure(0, weight=1)
        row = AutoInc(start=0)
        self.plugdir = tk.StringVar()
        self.plugdir.set(str(config.get_str('plugin_dir')))
        # LANG: Label for location of third-party plugins folder
        self.plugdir_label = nb.Label(plugins_frame, text=tr.tl('Plugins folder') + ':')
        self.plugdir_label.grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get())
        self.plugdir_entry = ttk.Entry(plugins_frame, takefocus=False,
                                       textvariable=self.plugdir)  # Link StringVar to Entry widget
        self.plugdir_entry.grid(columnspan=4, padx=self.PADX, pady=self.BOXY, sticky=tk.EW, row=row.get())
        with row as cur_row:
            nb.Label(
                plugins_frame,
                # Help text in settings
                # LANG: Tip/label about how to disable plugins
                text=tr.tl(
                    "Tip: You can disable a plugin by{CR}adding '{EXT}' to its folder name").format(EXT='.disabled')
            ).grid(columnspan=1, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row)

            # Open Plugin Folder Button
            self.open_plug_folder_btn = ttk.Button(
                plugins_frame,
                # LANG: Label on button used to open the Plugin Folder
                text=tr.tl('Open Plugins Folder'),
                command=lambda: open_folder(config.plugin_dir_path)
            )
            self.open_plug_folder_btn.grid(column=1, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row)

            # Browse Button
            text = tr.tl('Browse...')  # LANG: NOT-macOS Settings - files location selection button
            self.plugbutton = ttk.Button(
                plugins_frame,
                text=text,
                # LANG: Selecting the Location of the Plugin Directory on the Filesystem
                command=lambda: self.filebrowse(tr.tl('Plugin Directory Location'), self.plugdir)
            )
            self.plugbutton.grid(column=2, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row)

            if config.default_journal_dir_path:
                # Appearance theme and language setting
                ttk.Button(
                    plugins_frame,
                    # LANG: Settings > Configuration - Label on 'reset journal files location to default' button
                    text=tr.tl('Default'),
                    command=self.plugdir_reset,
                    state=tk.NORMAL if config.get_str('plugin_dir') else tk.DISABLED
                ).grid(column=3, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row)

        enabled_plugins = list(filter(lambda x: x.folder and x.module, plug.PLUGINS))
        if enabled_plugins:
            ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(
                columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
            )
            nb.Label(
                plugins_frame,
                # LANG: Label on list of enabled plugins
                text=tr.tl('Enabled Plugins')+':'  # List of plugins in settings
            ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get())

            for plugin in enabled_plugins:
                if plugin.name == plugin.folder:
                    label = nb.Label(plugins_frame, text=plugin.name)

                else:
                    label = nb.Label(plugins_frame, text=f'{plugin.folder} ({plugin.name})')

                label.grid(columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=row.get())

        ############################################################
        # Show which plugins don't have Python 3.x support
        ############################################################
        if plug.PLUGINS_not_py3:
            ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(
                columnspan=3, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
            )
            # LANG: Plugins - Label for list of 'enabled' plugins that don't work with Python 3.x
            nb.Label(plugins_frame, text=tr.tl('Plugins Without Python 3.x Support')+':').grid(
                padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get()
            )

            HyperlinkLabel(
                # LANG: Plugins - Label on URL to documentation about migrating plugins from Python 2.7
                plugins_frame, text=tr.tl('Information on migrating plugins'),
                background=nb.Label().cget('background'),
                url='https://github.com/EDCD/EDMarketConnector/blob/main/PLUGINS.md#migration-from-python-27',
                underline=True
            ).grid(columnspan=2, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get())

            for plugin in plug.PLUGINS_not_py3:
                if plugin.folder:  # 'system' ones have this set to None to suppress listing in Plugins prefs tab
                    nb.Label(plugins_frame, text=plugin.name).grid(
                        columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=row.get()
                    )
        ############################################################
        # Show disabled plugins
        ############################################################
        disabled_plugins = list(filter(lambda x: x.folder and not x.module, plug.PLUGINS))
        if disabled_plugins:
            ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(
                columnspan=3, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
            )
            nb.Label(
                plugins_frame,
                # LANG: Label on list of user-disabled plugins
                text=tr.tl('Disabled Plugins')+':'  # List of plugins in settings
            ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get())

            for plugin in disabled_plugins:
                nb.Label(plugins_frame, text=plugin.name).grid(
                    columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=row.get()
                )
        ############################################################
        # Show plugins that failed to load
        ############################################################
        if plug.PLUGINS_broken:
            ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(
                columnspan=3, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
            )
            # LANG: Plugins - Label for list of 'broken' plugins that failed to load
            nb.Label(plugins_frame, text=tr.tl('Broken Plugins')+':').grid(
                padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get()
            )

            for plugin in plug.PLUGINS_broken:
                if plugin.folder:  # 'system' ones have this set to None to suppress listing in Plugins prefs tab
                    nb.Label(plugins_frame, text=plugin.name).grid(
                        columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=row.get()
                    )

        # LANG: Label on Settings > Plugins tab
        notebook.add(plugins_frame, text=tr.tl('Plugins'))		# Tab heading in settings

    def cmdrchanged(self, event=None):
        """
        Notify plugins of cmdr change.

        :param event: Unused, defaults to None
        """
        if self.cmdr != monitor.cmdr or self.is_beta != monitor.is_beta:
            # Cmdr has changed - update settings
            if self.cmdr is not False:		# Don't notify on first run
                plug.notify_prefs_cmdr_changed(monitor.cmdr, monitor.is_beta)

            self.cmdr = monitor.cmdr
            self.is_beta = monitor.is_beta

        # Poll
        self.cmdrchanged_alarm = self.after(1000, self.cmdrchanged)

    def tabchanged(self, event: tk.Event) -> None:
        """Handle preferences active tab changing."""
        self.outvarchanged()

    def outvarchanged(self, event: Optional[tk.Event] = None) -> None:
        """Handle Output tab variable changes."""
        self.displaypath(self.outdir, self.outdir_entry)
        self.displaypath(self.logdir, self.logdir_entry)

        self.out_label['state'] = tk.NORMAL
        self.out_csv_button['state'] = tk.NORMAL
        self.out_td_button['state'] = tk.NORMAL
        self.out_ship_button['state'] = tk.NORMAL

    def filebrowse(self, title, pathvar):
        """
        Open a directory selection dialog.

        :param title: Title of the window
        :param pathvar: the path to start the dialog on
        """
        import tkinter.filedialog
        directory = tkinter.filedialog.askdirectory(
            parent=self,
            initialdir=Path(pathvar.get()).expanduser(),
            title=title,
            mustexist=tk.TRUE
        )

        if directory:
            pathvar.set(directory)
            self.outvarchanged()

    def displaypath(self, pathvar: tk.StringVar, entryfield: tk.Entry) -> None:
        """
        Display a path in a locked tk.Entry.

        :param pathvar: the path to display
        :param entryfield: the entry in which to display the path
        """
        # TODO: This is awful.
        entryfield['state'] = tk.NORMAL  # must be writable to update
        entryfield.delete(0, tk.END)
        if sys.platform == 'win32':
            start = len(config.home.split('\\')) if pathvar.get().lower().startswith(config.home.lower()) else 0
            display = []
            components = Path(pathvar.get()).resolve().parts
            buf = ctypes.create_unicode_buffer(MAX_PATH)
            pidsRes = ctypes.c_int()  # noqa: N806 # Windows convention
            for i in range(start, len(components)):
                try:
                    if (not SHGetLocalizedName('\\'.join(components[:i+1]), buf, MAX_PATH, ctypes.byref(pidsRes)) and
                            win32api.LoadString(ctypes.WinDLL(expandvars(buf.value))._handle,
                                                pidsRes.value, buf, MAX_PATH)):
                        display.append(buf.value)

                    else:
                        display.append(components[i])

                except Exception:
                    display.append(components[i])

            entryfield.insert(0, '\\'.join(display))

        #                                                   None if path doesn't exist
        else:
            if pathvar.get().startswith(config.home):
                entryfield.insert(0, '~' + pathvar.get()[len(config.home):])

            else:
                entryfield.insert(0, pathvar.get())

        entryfield['state'] = 'readonly'

    def logdir_reset(self) -> None:
        """Reset the log dir to the default."""
        if config.default_journal_dir_path:
            self.logdir.set(config.default_journal_dir)

        self.outvarchanged()

    def plugdir_reset(self) -> None:
        """Reset the log dir to the default."""
        if config.default_plugin_dir_path:
            self.plugdir.set(config.default_plugin_dir)

        self.outvarchanged()

    def disable_autoappupdatecheckingame_changed(self) -> None:
        """Save out the auto update check in game config."""
        config.set('disable_autoappupdatecheckingame', self.disable_autoappupdatecheckingame.get())
        # If it's now False, re-enable WinSparkle ?  Need access to the AppWindow.updater variable to call down

    def alt_shipyard_open_changed(self) -> None:
        """Save out the status of the alt shipyard config."""
        config.set('use_alt_shipyard_open', self.alt_shipyard_open.get())

    def themecolorbrowse(self, index: int) -> None:
        """
        Show a color browser.

        :param index: Index of the color type, 0 for dark text, 1 for dark highlight
        """
        (_, color) = tkColorChooser.askcolor(
            self.theme_colors[index], title=self.theme_prompts[index], parent=self.parent
        )

        if color:
            self.theme_colors[index] = color
            self.themevarchanged()

    def themevarchanged(self) -> None:
        """Update theme examples."""
        self.theme_button_0['foreground'], self.theme_button_1['foreground'] = self.theme_colors

        if self.theme.get() == theme.THEME_DEFAULT:
            state = tk.DISABLED  # type: ignore

        else:
            state = tk.NORMAL  # type: ignore

        self.theme_label_0['state'] = state
        self.theme_label_1['state'] = state
        self.theme_button_0['state'] = state
        self.theme_button_1['state'] = state

    def hotkeystart(self, event: 'tk.Event[Any]') -> None:
        """Start listening for hotkeys."""
        event.widget.bind('<KeyPress>', self.hotkeylisten)
        event.widget.bind('<KeyRelease>', self.hotkeylisten)
        event.widget.delete(0, tk.END)
        hotkeymgr.acquire_start()

    def hotkeyend(self, event: 'tk.Event[Any]') -> None:
        """Stop listening for hotkeys."""
        event.widget.unbind('<KeyPress>')
        event.widget.unbind('<KeyRelease>')
        hotkeymgr.acquire_stop()  # in case focus was lost while in the middle of acquiring
        event.widget.delete(0, tk.END)
        self.hotkey_text.insert(
            0,
            # LANG: No hotkey/shortcut set
            hotkeymgr.display(self.hotkey_code, self.hotkey_mods) if self.hotkey_code else tr.tl('None'))

    def hotkeylisten(self, event: 'tk.Event[Any]') -> str:
        """
        Hotkey handler.

        :param event: tkinter event for the hotkey
        :return: "break" as a literal, to halt processing
        """
        good = hotkeymgr.fromevent(event)
        if good and isinstance(good, tuple):
            hotkey_code, hotkey_mods = good
            event.widget.delete(0, tk.END)
            event.widget.insert(0, hotkeymgr.display(hotkey_code, hotkey_mods))
            if hotkey_code:
                # done
                (self.hotkey_code, self.hotkey_mods) = (hotkey_code, hotkey_mods)
                self.hotkey_only_btn['state'] = tk.NORMAL
                self.hotkey_play_btn['state'] = tk.NORMAL
                self.hotkey_only_btn.focus()  # move to next widget - calls hotkeyend() implicitly

        else:
            if good is None: 	# clear
                (self.hotkey_code, self.hotkey_mods) = (0, 0)
            event.widget.delete(0, tk.END)

            if self.hotkey_code:
                event.widget.insert(0, hotkeymgr.display(self.hotkey_code, self.hotkey_mods))
                self.hotkey_only_btn['state'] = tk.NORMAL
                self.hotkey_play_btn['state'] = tk.NORMAL

            else:
                # LANG: No hotkey/shortcut set
                event.widget.insert(0, tr.tl('None'))
                self.hotkey_only_btn['state'] = tk.DISABLED
                self.hotkey_play_btn['state'] = tk.DISABLED

            self.hotkey_only_btn.focus()  # move to next widget - calls hotkeyend() implicitly

        return 'break'  # stops further processing - insertion, Tab traversal etc

    def apply(self) -> None:  # noqa: CCR001
        """Update the config with the options set on the dialog."""
        config.set('PrefsVersion', prefsVersion.stringToSerial(appversion_nobuild()))
        config.set(
            'output',
            (self.out_td.get() and config.OUT_MKT_TD) +
            (self.out_csv.get() and config.OUT_MKT_CSV) +
            (config.OUT_MKT_MANUAL if not self.out_auto.get() else 0) +
            (self.out_ship.get() and config.OUT_SHIP) +
            (config.get_int('output') & (
                config.OUT_EDDN_SEND_STATION_DATA | config.OUT_EDDN_SEND_NON_STATION | config.OUT_EDDN_DELAY
            ))
        )

        config.set(
            'outdir',
            str(config.home_path / self.outdir.get()[2:]) if self.outdir.get().startswith('~') else self.outdir.get()
        )

        logdir = self.logdir.get()
        if config.default_journal_dir_path and logdir.lower() == config.default_journal_dir.lower():
            config.set('journaldir', '')  # default location

        else:
            config.set('journaldir', logdir)

        config.set('capi_fleetcarrier', self.capi_fleetcarrier.get())

        if sys.platform == 'win32':
            config.set('hotkey_code', self.hotkey_code)
            config.set('hotkey_mods', self.hotkey_mods)
            config.set('hotkey_always', int(not self.hotkey_only.get()))
            config.set('hotkey_mute', int(not self.hotkey_play.get()))
        config.set('beta_optin', 0 if self.update_paths.get() == "Stable" else 1)
        config.set('shipyard_provider', self.shipyard_provider.get())
        config.set('system_provider', self.system_provider.get())
        config.set('station_provider', self.station_provider.get())
        config.set('loglevel', self.select_loglevel.get())
        edmclogger.set_console_loglevel(self.select_loglevel.get())

        lang_codes = {v: k for k, v in self.languages.items()}  # Codes by name
        config.set('language', lang_codes.get(self.lang.get()) or '')  # or '' used here due to Default being None above
        tr.install(config.get_str('language', default=None))  # type: ignore # This sets self in weird ways.

        # Privacy options
        config.set('hide_private_group', self.hide_private_group.get())
        config.set('hide_multicrew_captain', self.hide_multicrew_captain.get())

        config.set('ui_scale', self.ui_scale.get())
        config.set('ui_transparency', self.transparency.get())
        config.set('always_ontop', self.always_ontop.get())
        config.set('minimize_system_tray', self.minimize_system_tray.get())
        config.set('theme', self.theme.get())
        config.set('dark_text', self.theme_colors[0])
        config.set('dark_highlight', self.theme_colors[1])
        theme.apply(self.parent)
        if self.plugdir.get() != config.get('plugin_dir'):
            config.set(
                'plugin_dir',
                str(Path(config.home_path, self.plugdir.get()[2:])) if self.plugdir.get().startswith('~') else
                str(Path(self.plugdir.get()))
            )
            self.req_restart = True

        # Notify
        if self.callback:
            self.callback()

        plug.notify_prefs_changed(monitor.cmdr, monitor.is_beta)

        self._destroy()
        # Send to the Post Config if we updated the update branch or need to restart
        post_flags = {
            'Update': True if self.curr_update_track != self.update_paths.get() else False,
            'Track': self.update_paths.get(),
            'Parent': self,
            'Restart_Req': True if self.req_restart else False
        }
        if self.callback:
            self.callback(**post_flags)

    def _destroy(self) -> None:
        """widget.destroy wrapper that does some extra cleanup."""
        if self.cmdrchanged_alarm is not None:
            self.after_cancel(self.cmdrchanged_alarm)
            self.cmdrchanged_alarm = None

        self.parent.wm_attributes('-topmost', 1 if config.get_int('always_ontop') else 0)
        self.destroy()