# -*- coding: utf-8 -*-
"""EDMC preferences library."""

import contextlib
import logging
import sys
import tkinter as tk
import webbrowser
from os.path import expanduser, expandvars, join, normpath
from tkinter import colorchooser as tkColorChooser  # type: ignore # noqa: N812
from tkinter import ttk
from types import TracebackType
from typing import TYPE_CHECKING, Any, Callable, Optional, Type, Union

import myNotebook as nb  # noqa: N813
import plug
from config import applongname, appversion_nobuild, config
from EDMCLogging import edmclogger, get_main_logger
from hotkey import hotkeymgr
from l10n import Translations
from monitor import monitor
from myNotebook import Notebook
from theme import theme
from ttkHyperlinkLabel import HyperlinkLabel

logger = get_main_logger()

if TYPE_CHECKING:
    def _(x: str) -> str:
        return x

# 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


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 == 'darwin':
    import objc  # type: ignore
    from Foundation import NSFileManager  # type: ignore
    try:
        from ApplicationServices import (  # type: ignore
            AXIsProcessTrusted, AXIsProcessTrustedWithOptions, kAXTrustedCheckOptionPrompt
        )

    except ImportError:
        HIServices = objc.loadBundle(
            'HIServices',
            globals(),
            '/System/Library/Frameworks/ApplicationServices.framework/Frameworks/HIServices.framework'
        )

        objc.loadBundleFunctions(
            HIServices,
            globals(),
            [('AXIsProcessTrusted', 'B'), ('AXIsProcessTrustedWithOptions', 'B@')]
        )

        objc.loadBundleVariables(HIServices, globals(), [('kAXTrustedCheckOptionPrompt', '@^{__CFString=}')])

    was_accessible_at_launch = AXIsProcessTrusted()  # type: ignore

elif sys.platform == 'win32':
    import ctypes
    import winreg
    from ctypes.wintypes import HINSTANCE, HWND, LPARAM, LPCWSTR, LPVOID, LPWSTR, MAX_PATH, POINT, RECT, SIZE, UINT
    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:
        pass

    # https://msdn.microsoft.com/en-us/library/windows/desktop/bb762115
    BIF_RETURNONLYFSDIRS = 0x00000001
    BIF_USENEWUI = 0x00000050
    BFFM_INITIALIZED = 1
    BFFM_SETSELECTION = 0x00000467
    BrowseCallbackProc = ctypes.WINFUNCTYPE(ctypes.c_int, HWND, ctypes.c_uint, LPARAM, LPARAM)

    class BROWSEINFO(ctypes.Structure):
        """Windows file browser fields."""

        _fields_ = [("hwndOwner", HWND), ("pidlRoot", LPVOID), ("pszDisplayName", LPWSTR), ("lpszTitle", LPCWSTR),
                    ("ulFlags", UINT), ("lpfn", BrowseCallbackProc), ("lParam", LPCWSTR), ("iImage", ctypes.c_int)]

    CalculatePopupWindowPosition = None
    if not is_wine:
        try:
            CalculatePopupWindowPosition = ctypes.windll.user32.CalculatePopupWindowPosition

        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)
            ]

            GetParent = ctypes.windll.user32.GetParent
            GetParent.argtypes = [HWND]
            GetWindowRect = ctypes.windll.user32.GetWindowRect
            GetWindowRect.argtypes = [HWND, ctypes.POINTER(RECT)]

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

    LoadString = ctypes.windll.user32.LoadStringW
    LoadString.argtypes = [HINSTANCE, UINT, LPWSTR, ctypes.c_int]


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

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

        self.parent = parent
        self.callback = callback
        if sys.platform == 'darwin':
            # LANG: File > Preferences menu entry for macOS
            self.title(_('Preferences'))

        else:
            # LANG: File > Settings (macOS)
            self.title(_('Settings'))

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

        # position over parent
        if sys.platform != 'darwin' or parent.winfo_rooty() > 0:  # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
            # TODO this is fixed supposedly.
            self.geometry(f'+{parent.winfo_rootx()}+{parent.winfo_rooty()}')

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

        elif sys.platform == 'darwin':
            # http://wiki.tcl.tk/13428
            parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility')

        self.resizable(tk.FALSE, tk.FALSE)

        self.cmdr: Union[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

        frame = ttk.Frame(self)
        frame.grid(sticky=tk.NSEW)

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

        self.PADX = 10
        self.BUTTONX = 12  # indent Checkbuttons and Radiobuttons
        self.PADY = 2  # close spacing

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

        if sys.platform == 'darwin':
            self.protocol("WM_DELETE_WINDOW", self.apply)  # close button applies changes

        else:
            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=_('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)

        # 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 ontop of parent on OSX & Linux
        self.wait_visibility()
        self.grab_set()

        # Ensure fully on-screen
        if sys.platform == 'win32' and CalculatePopupWindowPosition:
            position = RECT()
            GetWindowRect(GetParent(self.winfo_id()), position)
            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}")

    def __setup_output_tab(self, root_notebook: nb.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=1)

        # LANG: Settings > Output - choosing what data to save to files
        self.out_label = nb.Label(output_frame, text=_('Please choose what data to save'))
        self.out_label.grid(columnspan=2, padx=self.PADX, 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=_('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, 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=_('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, 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=_('Ship loadout'),  # LANG: Settings > Output option
            variable=self.out_ship,
            command=self.outvarchanged
        )
        self.out_ship_button.grid(columnspan=2, padx=self.BUTTONX, pady=(5, 0), 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=_('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=(5, 0), 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=_('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=(5, 0), sticky=tk.W, row=row.get())  # type: ignore

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

        if sys.platform == 'darwin':
            text = (_('Change...'))  # LANG: macOS Preferences - files location selection button

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

        self.outbutton = nb.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(_('File location'), self.outdir)
        )
        self.outbutton.grid(column=1, padx=self.PADX, pady=self.PADY, sticky=tk.NSEW, row=row.get())

        nb.Frame(output_frame).grid(row=row.get())  # bottom spacer # TODO: does nothing?

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

    def __setup_plugin_tabs(self, notebook: 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: Notebook) -> None:  # noqa: CCR001
        config_frame = nb.Frame(notebook)
        config_frame.columnconfigure(1, weight=1)
        row = AutoInc(start=1)

        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 = nb.Entry(config_frame, takefocus=False)

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

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

        if sys.platform == 'darwin':
            text = (_('Change...'))  # LANG: macOS Preferences - files location selection button

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

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

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

        if sys.platform in ('darwin', 'win32'):
            ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid(
                columnspan=4, padx=self.PADX, pady=self.PADY*4, 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'))
            nb.Label(
                config_frame,
                text=_('Keyboard shortcut') if  # LANG: Hotkey/Shortcut settings prompt on OSX
                sys.platform == 'darwin' else
                _('Hotkey')  # LANG: Hotkey/Shortcut settings prompt on Windows
            ).grid(padx=self.PADX, sticky=tk.W, row=row.get())

            if sys.platform == 'darwin' and not was_accessible_at_launch:
                if AXIsProcessTrusted():
                    # Shortcut settings prompt on OSX
                    nb.Label(
                        config_frame,
                        # LANG: macOS Preferences > Configuration - restart the app message
                        text=_('Re-start {APP} to use shortcuts').format(APP=applongname),
                        foreground='firebrick'
                    ).grid(padx=self.PADX, sticky=tk.W, row=row.get())

                else:
                    # Shortcut settings prompt on OSX
                    nb.Label(
                        config_frame,
                        # LANG: macOS - Configuration - need to grant the app permission for keyboard shortcuts
                        text=_('{APP} needs permission to use shortcuts').format(APP=applongname),
                        foreground='firebrick'
                    ).grid(columnspan=4, padx=self.PADX, sticky=tk.W, row=row.get())

                    # LANG: Shortcut settings button on OSX
                    nb.Button(config_frame, text=_('Open System Preferences'), command=self.enableshortcuts).grid(
                        padx=self.PADX, sticky=tk.E, row=row.get()
                    )

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

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

                # Hotkey/Shortcut setting
                self.hotkey_only_btn = nb.Checkbutton(
                    config_frame,
                    # LANG: Configuration - Act on hotkey only when ED is in foreground
                    text=_('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.PADX, pady=(5, 0), 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=_('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.PADX, sticky=tk.W, row=row.get())

        # Option to disabled Automatic Check For Updates whilst in-game
        ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid(
            columnspan=4, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW, row=row.get()
        )
        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=_('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.PADX, sticky=tk.W, row=row.get())

        ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid(
            columnspan=4, padx=self.PADX, pady=self.PADY*4, 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=_('Preferred websites')).grid(
            columnspan=4, padx=self.PADX, 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=_('Shipyard')).grid(padx=self.PADX, pady=2*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, 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=_('Use alternate URL method'),
                variable=self.alt_shipyard_open,
                command=self.alt_shipyard_open_changed,
            )

            self.alt_shipyard_open_btn.grid(column=2, 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=_('System')).grid(padx=self.PADX, pady=2*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, 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 'eddb')
            )

            # LANG: Configuration - Label for selection of 'Station' provider website
            nb.Label(config_frame, text=_('Station')).grid(padx=self.PADX, pady=2*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, sticky=tk.W, row=cur_row)

        # Set loglevel
        ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid(
            columnspan=4, padx=self.PADX, pady=self.PADY*4, 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=_('Log Level')
            ).grid(padx=self.PADX, pady=2*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, sticky=tk.W, 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=_('Configuration'))

    def __setup_privacy_tab(self, notebook: Notebook) -> None:
        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()

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

        nb.Checkbutton(
            frame, text=_('Hide private group name in UI'),  # LANG: Hide private group owner name from UI checkbox
            variable=self.hide_private_group
        ).grid(row=row.get(), column=0, padx=self.PADX, pady=self.PADY)
        nb.Checkbutton(
            frame, text=_('Hide multi-crew captain name'),  # LANG: Hide multicrew captain name from main UI checkbox
            variable=self.hide_multicrew_captain
        ).grid(row=row.get(), column=0, padx=self.PADX, pady=self.PADY)

        notebook.add(frame, text=_('Privacy'))  # LANG: Preferences privacy tab title

    def __setup_appearance_tab(self, notebook: Notebook) -> None:
        self.languages = Translations.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'), _('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
            _('Normal text'),		# Dark theme color setting
            # LANG: Label for Settings > Appeareance > selection of 'highlightes' text colour
            _('Highlighted text'),  # Dark theme color setting
        ]

        row = AutoInc(start=1)

        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=_('Language')).grid(padx=self.PADX, 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=self.PADX, sticky=tk.W, row=cur_row)

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

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

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

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

        if sys.platform == 'win32':
            nb.Radiobutton(
                appearance_frame,
                # LANG: Label for 'Transparent' theme radio button
                text=_('Transparent'),  # Appearance theme setting
                variable=self.theme,
                value=2,
                command=self.themevarchanged
            ).grid(columnspan=3, padx=self.BUTTONX, 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, sticky=tk.W, row=cur_row)

            # Main window
            self.theme_button_0 = nb.ColoredButton(
                appearance_frame,
                # LANG: Appearance - Example 'Normal' text
                text=_('Station'),
                background='grey4',
                command=lambda: self.themecolorbrowse(0)
            )

            self.theme_button_0.grid(column=1, padx=self.PADX, pady=self.PADY, 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, sticky=tk.W, row=cur_row)
            self.theme_button_1 = nb.ColoredButton(
                appearance_frame,
                text='  Hutton Orbital  ',  # Do not translate
                background='grey4',
                command=lambda: self.themecolorbrowse(1)
            )

            self.theme_button_1.grid(column=1, padx=self.PADX, pady=self.PADY, 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.PADY*4, sticky=tk.EW, row=row.get()
        )
        with row as cur_row:
            # LANG: Appearance - Label for selection of UI scaling
            nb.Label(appearance_frame, text=_('UI Scale Percentage')).grid(
                padx=self.PADX, pady=2*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, sticky=tk.W, row=cur_row)
            self.ui_scaling_defaultis = nb.Label(
                appearance_frame,
                # LANG: Appearance - Help/hint text for UI scaling selection
                text=_('100 means Default{CR}Restart Required for{CR}changes to take effect!')
            ).grid(column=3, padx=self.PADX, pady=2*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.PADY*4, 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=_("Main window transparency")).grid(
                padx=self.PADX, pady=self.PADY*2, 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=_(
                    "100 means fully opaque.{CR}"
                    "Window is updated in real time"
                ).format(CR='\n')
            ).grid(
                column=3,
                padx=self.PADX,
                pady=self.PADY*2,
                sticky=tk.E,
                row=cur_row
            )

            self.transparency_bar.grid(column=1, sticky=tk.W, row=cur_row)

        # Always on top
        ttk.Separator(appearance_frame, orient=tk.HORIZONTAL).grid(
            columnspan=4, padx=self.PADX, pady=self.PADY*4, 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=_('Always on top'),
            variable=self.always_ontop,
            command=self.themevarchanged
        )
        self.ontop_button.grid(columnspan=3, padx=self.BUTTONX, 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=_('Minimize to system tray'),
                variable=self.minimize_system_tray,
                command=self.themevarchanged
            ).grid(columnspan=3, padx=self.BUTTONX, 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=_('Appearance'))  # Tab heading in settings

    def __setup_plugin_tab(self, notebook: Notebook) -> None:  # noqa: CCR001
        # Plugin settings and info
        plugins_frame = nb.Frame(notebook)
        plugins_frame.columnconfigure(0, weight=1)
        plugdir = tk.StringVar()
        plugdir.set(config.plugin_dir)
        row = AutoInc(1)

        plugdirentry = nb.Entry(plugins_frame, justify=tk.LEFT)
        self.displaypath(plugdir, plugdirentry)
        with row as cur_row:
            # Section heading in settings
            # LANG: Label for location of third-party plugins folder
            nb.Label(plugins_frame, text=_('Plugins folder') + ':').grid(padx=self.PADX, sticky=tk.W, row=cur_row)

            plugdirentry.grid(padx=self.PADX, sticky=tk.EW, row=cur_row)

            nb.Button(
                plugins_frame,
                # LANG: Label on button used to open a filesystem folder
                text=_('Open'),  # Button that opens a folder in Explorer/Finder
                command=lambda: webbrowser.open(f'file:///{config.plugin_dir_path}')
            ).grid(column=1, padx=(0, self.PADX), sticky=tk.NSEW, row=cur_row)

        nb.Label(
            plugins_frame,
            # Help text in settings
            # LANG: Tip/label about how to disable plugins
            text=_("Tip: You can disable a plugin by{CR}adding '{EXT}' to its folder name").format(EXT='.disabled')
        ).grid(columnspan=2, padx=self.PADX, pady=10, sticky=tk.NSEW, row=row.get())

        enabled_plugins = list(filter(lambda x: x.folder and x.module, plug.PLUGINS))
        if len(enabled_plugins):
            ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(
                columnspan=3, padx=self.PADX, pady=self.PADY * 8, sticky=tk.EW
            )
            nb.Label(
                plugins_frame,
                # LANG: Label on list of enabled plugins
                text=_('Enabled Plugins')+':'  # List of plugins in settings
            ).grid(padx=self.PADX, 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.PADX*2, sticky=tk.W, row=row.get())

        ############################################################
        # Show which plugins don't have Python 3.x support
        ############################################################
        if len(plug.PLUGINS_not_py3):
            ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(
                columnspan=3, padx=self.PADX, pady=self.PADY * 8, 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=_('Plugins Without Python 3.x Support:')+':').grid(padx=self.PADX, sticky=tk.W)

            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.PADX*2, sticky=tk.W)

            HyperlinkLabel(
                # LANG: Plugins - Label on URL to documentation about migrating plugins from Python 2.7
                plugins_frame, text=_('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, sticky=tk.W)
        ############################################################

        disabled_plugins = list(filter(lambda x: x.folder and not x.module, plug.PLUGINS))
        if len(disabled_plugins):
            ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(
                columnspan=3, padx=self.PADX, pady=self.PADY * 8, sticky=tk.EW, row=row.get()
            )
            nb.Label(
                plugins_frame,
                # LANG: Lable on list of user-disabled plugins
                text=_('Disabled Plugins')+':'  # List of plugins in settings
            ).grid(padx=self.PADX, sticky=tk.W, row=row.get())

            for plugin in disabled_plugins:
                nb.Label(plugins_frame, text=plugin.name).grid(
                    columnspan=2, padx=self.PADX*2, sticky=tk.W, row=row.get()
                )

        # LANG: Label on Settings > Plugins tab
        notebook.add(plugins_frame, text=_('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()
        if sys.platform == 'darwin':
            # Hack to recompute size so that buttons show up under Mojave
            notebook = event.widget
            frame = self.nametowidget(notebook.winfo_parent())
            temp = nb.Label(frame)
            temp.grid()
            temp.update_idletasks()
            temp.destroy()

    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 locale

        # If encoding isn't UTF-8 we can't use the tkinter dialog
        current_locale = locale.getlocale(locale.LC_CTYPE)
        directory = None
        if sys.platform == 'win32' and current_locale[1] not in ('utf8', 'UTF8', 'utf-8', 'UTF-8'):
            def browsecallback(hwnd, uMsg, lParam, lpData):  # noqa: N803 # Windows API convention
                # set initial folder
                if uMsg == BFFM_INITIALIZED and lpData:
                    ctypes.windll.user32.SendMessageW(hwnd, BFFM_SETSELECTION, 1, lpData)
                return 0

            browseInfo = BROWSEINFO()  # noqa: N806 # Windows convention
            browseInfo.lpszTitle = title
            browseInfo.ulFlags = BIF_RETURNONLYFSDIRS | BIF_USENEWUI
            browseInfo.lpfn = BrowseCallbackProc(browsecallback)
            browseInfo.lParam = pathvar.get().startswith('~') and join(config.home_path,
                                                                       pathvar.get()[2:]) or pathvar.get()
            ctypes.windll.ole32.CoInitialize(None)
            pidl = ctypes.windll.shell32.SHBrowseForFolderW(ctypes.byref(browseInfo))
            if pidl:
                path = ctypes.create_unicode_buffer(MAX_PATH)
                ctypes.windll.shell32.SHGetPathFromIDListW(pidl, path)
                ctypes.windll.ole32.CoTaskMemFree(pidl)
                directory = path.value
            else:
                directory = None

        else:
            import tkinter.filedialog
            directory = tkinter.filedialog.askdirectory(
                parent=self,
                initialdir=expanduser(pathvar.get()),
                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 = normpath(pathvar.get()).split('\\')
            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
                            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
        elif sys.platform == 'darwin' and NSFileManager.defaultManager().componentsToDisplayForPath_(pathvar.get()):
            if pathvar.get().startswith(config.home):
                display = ['~'] + NSFileManager.defaultManager().componentsToDisplayForPath_(pathvar.get())[
                    len(NSFileManager.defaultManager().componentsToDisplayForPath_(config.home)):
                ]

            else:
                display = NSFileManager.defaultManager().componentsToDisplayForPath_(pathvar.get())

            entryfield.insert(0, '/'.join(display))
        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 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

        state = tk.NORMAL if self.theme.get() else tk.DISABLED
        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 _('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:
            (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, _('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:
        """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_MKT_EDDN | config.OUT_SYS_EDDN | config.OUT_SYS_DELAY))
        )

        config.set(
            'outdir',
            join(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)

        if sys.platform in ('darwin', '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('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
        Translations.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)

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

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

        self._destroy()

    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()

    if sys.platform == 'darwin':
        def enableshortcuts(self) -> None:
            """Set up macOS preferences shortcut."""
            self.apply()
            # popup System Preferences dialog
            try:
                # http://stackoverflow.com/questions/6652598/cocoa-button-opens-a-system-preference-page/6658201
                from ScriptingBridge import SBApplication  # type: ignore
                sysprefs = 'com.apple.systempreferences'
                prefs = SBApplication.applicationWithBundleIdentifier_(sysprefs)
                pane = [x for x in prefs.panes() if x.id() == 'com.apple.preference.security'][0]
                prefs.setCurrentPane_(pane)
                anchor = [x for x in pane.anchors() if x.name() == 'Privacy_Accessibility'][0]
                anchor.reveal()
                prefs.activate()

            except Exception:
                AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True})

            if not config.shutting_down:
                self.parent.event_generate('<<Quit>>', when="tail")
