From 315258d3e72e5b2feae073f4447a8ebde020a191 Mon Sep 17 00:00:00 2001
From: A_D <aunderscored@gmail.com>
Date: Wed, 16 Sep 2020 19:43:02 +0200
Subject: [PATCH] Added row tracking variables to config tabs

This adds a new class called AutoInc, which is a self-incrementing
integer that supports use as a context manager. AutoInc is used to keep
track of row numbers automatically for easy addition to config panes,
and the context manager adds a visual clue to where entries are on the
same row but different columns
---
 prefs.py | 552 ++++++++++++++++++++++++++++++++-----------------------
 1 file changed, 320 insertions(+), 232 deletions(-)

diff --git a/prefs.py b/prefs.py
index ba675080..63573a40 100644
--- a/prefs.py
+++ b/prefs.py
@@ -3,12 +3,13 @@
 
 import logging
 import tkinter as tk
+from types import TracebackType
 import webbrowser
 from os.path import exists, expanduser, expandvars, join, normpath
 from sys import platform
 from tkinter import Variable, colorchooser as tkColorChooser  # type: ignore
 from tkinter import ttk
-from typing import TYPE_CHECKING, Any, Callable, Optional, Union
+from typing import TYPE_CHECKING, Any, Callable, Optional, Type, Union
 
 import myNotebook as nb
 from myNotebook import Notebook
@@ -21,6 +22,8 @@ from monitor import monitor
 from theme import theme
 from ttkHyperlinkLabel import HyperlinkLabel
 
+import contextlib
+
 logger = logging.getLogger(appname)
 
 if TYPE_CHECKING:
@@ -113,8 +116,48 @@ class PrefsVersion:
         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 platform == 'darwin':
@@ -144,6 +187,7 @@ if platform == 'darwin':
 
 elif platform == 'win32':
     # sigh tkFileDialog.askdirectory doesn't support unicode on Windows
+    # TODO: Remove this
     import ctypes
     import ctypes.windll  # type: ignore # I promise pylance, its there.
     from ctypes.wintypes import HINSTANCE, HWND, LPARAM, LPCWSTR, LPVOID, LPWSTR, MAX_PATH, POINT, RECT, SIZE, UINT
@@ -246,7 +290,7 @@ class PreferencesDialog(tk.Toplevel):
         self.__setup_output_tab(notebook)
         self.__setup_plugin_tabs(notebook)
         self.__setup_config_tab(notebook)
-        self.__setup_theme_tab(notebook)
+        self.__setup_appearance_tab(notebook)
         self.__setup_plugin_tab(notebook)
 
         if platform == 'darwin':
@@ -287,8 +331,8 @@ class PreferencesDialog(tk.Toplevel):
                 self.geometry(f"+{position.left}+{position.top}")
 
     def __setup_output_tab(self, root_notebook: nb.Notebook) -> None:
-        outframe = nb.Frame(root_notebook)
-        outframe.columnconfigure(0, weight=1)
+        output_frame = nb.Frame(root_notebook)
+        output_frame.columnconfigure(0, weight=1)
 
         if prefsVersion.shouldSetDefaults('0.0.0.0', not bool(config.getint('output'))):
             output = config.OUT_SHIP  # default settings
@@ -296,157 +340,151 @@ class PreferencesDialog(tk.Toplevel):
         else:
             output = config.getint('output')
 
-        row = 0
+        row = AutoInc(start=1)
 
         # TODO: *All* of this needs to use a 'row' variable, incremented after
         #      adding one to keep track, so it's easier to insert new rows in
         #      the middle without worrying about updating `row=X` elements.
-        self.out_label = nb.Label(outframe, text=_('Please choose what data to save'))
-        self.out_label.grid(columnspan=2, padx=self.PADX, sticky=tk.W, row=row)
-
-        row += 1
+        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(
-            outframe,
+            output_frame,
             text=_('Market data in CSV format file'),
             variable=self.out_csv,
             command=self.outvarchanged
         )
-        self.out_csv_button.grid(columnspan=2, padx=self.BUTTONX, sticky=tk.W, row=row)
-
-        row += 1
+        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(
-            outframe,
+            output_frame,
             text=_('Market data in Trade Dangerous format file'),
             variable=self.out_td,
             command=self.outvarchanged
         )
-        self.out_td_button.grid(columnspan=2, padx=self.BUTTONX, sticky=tk.W, row=row)
+        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)
 
-        row += 1
-
         # Output setting
         self.out_ship_button = nb.Checkbutton(
-            outframe,
+            output_frame,
             text=_('Ship loadout'),
             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)
+        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
 
-        row += 1
-
         # Output setting
         self.out_auto_button = nb.Checkbutton(
-            outframe,
+            output_frame,
             text=_('Automatically update on docking'),
             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)
-
-        row += 1
+        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('outdir')))
-        self.outdir_label = nb.Label(outframe, text=_('File location')+':')  # Section heading in settings
-        self.outdir_label.grid(padx=self.PADX, pady=(5, 0), sticky=tk.W, row=row)  # type: ignore # 2 tuple, each side
+        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
         
-        row += 1
-        
-        self.outdir_entry = nb.Entry(outframe, takefocus=False)
-        self.outdir_entry.grid(columnspan=2, padx=self.PADX, pady=(0, self.PADY), sticky=tk.EW, row=row)
+        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())
 
-        row += 1
-        
         self.outbutton = nb.Button(
-            outframe,
+            output_frame,
             text=(_('Change...') if platform == 'darwin' else _('Browse...')),
             command=lambda: self.filebrowse(_('File location'), self.outdir)
         )
-        self.outbutton.grid(column=1, padx=self.PADX, pady=self.PADY, sticky=tk.NSEW, row=row)
+        self.outbutton.grid(column=1, padx=self.PADX, pady=self.PADY, sticky=tk.NSEW, row=row.get())
         
-        row += 1
-        
-        nb.Frame(outframe).grid(row=row)  # bottom spacer # TODO: does nothing?
+        nb.Frame(output_frame).grid(row=row.get())  # bottom spacer # TODO: does nothing?
 
-        root_notebook.add(outframe, text=_('Output'))		# Tab heading in settings
+        root_notebook.add(output_frame, text=_('Output'))		# Tab heading in settings
 
     def __setup_plugin_tabs(self, notebook: Notebook) -> None:
         for plugin in plug.PLUGINS:
-            plugframe = plugin.get_prefs(notebook, monitor.cmdr, monitor.is_beta)
-            if plugframe:
-                notebook.add(plugframe, text=plugin.name)
+            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:
-        configframe = nb.Frame(notebook)
-        configframe.columnconfigure(1, weight=1)
+        config_frame = nb.Frame(notebook)
+        config_frame.columnconfigure(1, weight=1)
+        row = AutoInc(start=1)
 
         self.logdir = tk.StringVar()
         self.logdir.set(str(config.get('journaldir') or config.default_journal_dir or ''))
-        self.logdir_entry = nb.Entry(configframe, takefocus=False)
+        self.logdir_entry = nb.Entry(config_frame, takefocus=False)
 
         # Location of the new Journal file in E:D 2.2
         nb.Label(
-            configframe,
+            config_frame,
             text=_('E:D journal file location')+':'
-        ).grid(columnspan=4, padx=self.PADX, sticky=tk.W)
+        ).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())
 
-        self.logdir_entry.grid(columnspan=4, padx=self.PADX, pady=(0, self.PADY), sticky=tk.EW)
         self.logbutton = nb.Button(
-            configframe,
+            config_frame,
             text=(_('Change...') if platform == 'darwin' else _('Browse...')),
             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())
 
-        self.logbutton.grid(row=10, column=3, padx=self.PADX, pady=self.PADY, sticky=tk.EW)
         if config.default_journal_dir:
             # Appearance theme and language setting
             nb.Button(
-                configframe,
+                config_frame,
                 text=_('Default'),
                 command=self.logdir_reset,
                 state=tk.NORMAL if config.get('journaldir') else tk.DISABLED
-            ).grid(row=10, column=2, pady=self.PADY, sticky=tk.EW)
+            ).grid(column=2, pady=self.PADY, sticky=tk.EW, row=row.get())
+
+        if 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()
+            )
 
-        if platform in ['darwin', 'win32']:
-            ttk.Separator(configframe, orient=tk.HORIZONTAL).grid(columnspan=4, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW)
             self.hotkey_code = config.getint('hotkey_code')
             self.hotkey_mods = config.getint('hotkey_mods')
             self.hotkey_only = tk.IntVar(value=not config.getint('hotkey_always'))
             self.hotkey_play = tk.IntVar(value=not config.getint('hotkey_mute'))
             nb.Label(
-                configframe,
+                config_frame,
                 text=_('Keyboard shortcut') if  # Hotkey/Shortcut settings prompt on OSX
                 platform == 'darwin' else
                 _('Hotkey')		# Hotkey/Shortcut settings prompt on Windows
-            ).grid(row=20, padx=self.PADX, sticky=tk.W)
+            ).grid(padx=self.PADX, sticky=tk.W, row=row.get())
 
             if platform == 'darwin' and not was_accessible_at_launch:
                 if AXIsProcessTrusted():
-                    nb.Label(configframe, text=_('Re-start {APP} to use shortcuts').format(APP=applongname),
-                             foreground='firebrick').grid(padx=self.PADX, sticky=tk.W)  # Shortcut settings prompt on OSX
+                    # Shortcut settings prompt on OSX
+                    nb.Label(
+                        config_frame,
+                        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(
-                        configframe,
-                        text=_('{APP} needs permission to use shortcuts').format(
-                            APP=applongname
-                        ),
+                        config_frame,
+                        text=_('{APP} needs permission to use shortcuts').format(APP=applongname),
                         foreground='firebrick'
-                    ).grid(columnspan=4, padx=self.PADX, sticky=tk.W)
+                    ).grid(columnspan=4, padx=self.PADX, sticky=tk.W, row=row.get())
 
-                    nb.Button(configframe, text=_('Open System Preferences'), command=self.enableshortcuts).grid(
-                        padx=self.PADX, sticky=tk.E)		# Shortcut settings button on OSX
+                    # 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(configframe, width=(20 if platform == 'darwin' else 30), justify=tk.CENTER)
+                self.hotkey_text = nb.Entry(config_frame, width=(20 if platform == 'darwin' else 30), justify=tk.CENTER)
                 self.hotkey_text.insert(
                     0,
                     # No hotkey/shortcut currently defined
@@ -456,132 +494,149 @@ class PreferencesDialog(tk.Toplevel):
 
                 self.hotkey_text.bind('<FocusIn>', self.hotkeystart)
                 self.hotkey_text.bind('<FocusOut>', self.hotkeyend)
-                self.hotkey_text.grid(row=20, column=1, columnspan=2, pady=(5, 0), sticky=tk.W)
+                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(
-                    configframe,
+                    config_frame,
                     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)
+                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(
-                    configframe,
+                    config_frame,
                     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)
+                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(configframe, orient=tk.HORIZONTAL).grid(columnspan=4, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW)
+        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.getint('disable_autoappupdatecheckingame'))
         self.disable_autoappupdatecheckingame_btn = nb.Checkbutton(
-            configframe,
+            config_frame,
             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)
+        self.disable_autoappupdatecheckingame_btn.grid(columnspan=4, padx=self.PADX, sticky=tk.W, row=row.get())
 
-        ttk.Separator(configframe, orient=tk.HORIZONTAL).grid(columnspan=4, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW)
+        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
-        nb.Label(configframe, text=_('Preferred websites')).grid(row=30, columnspan=4, padx=self.PADX, sticky=tk.W)
-
-        self.shipyard_provider = tk.StringVar(
-            value=str(
-                config.get('shipyard_provider') in plug.provides('shipyard_url')
-                and config.get('shipyard_provider') or 'EDSY')
-        )
-        # Setting to decide which ship outfitting website to link to - either E:D Shipyard or Coriolis
-        nb.Label(configframe, text=_('Shipyard')).grid(row=31, padx=self.PADX, pady=2*self.PADY, sticky=tk.W)
-        self.shipyard_button = nb.OptionMenu(
-            configframe, self.shipyard_provider, self.shipyard_provider.get(), *plug.provides('shipyard_url')
+        nb.Label(config_frame, text=_('Preferred websites')).grid(
+            columnspan=4, padx=self.PADX, sticky=tk.W, row=row.get()
         )
 
-        self.shipyard_button.configure(width=15)
-        self.shipyard_button.grid(row=31, column=1, sticky=tk.W)
-        # Option for alternate URL opening
-        self.alt_shipyard_open = tk.IntVar(value=config.getint('use_alt_shipyard_open'))
-        self.alt_shipyard_open_btn = nb.Checkbutton(
-            configframe,
-            text=_('Use alternate URL method'),
-            variable=self.alt_shipyard_open,
-            command=self.alt_shipyard_open_changed,
-        )
+        with row as cur_row:
+            self.shipyard_provider = tk.StringVar(
+                value=str(
+                    config.get('shipyard_provider') in plug.provides('shipyard_url')
+                    and config.get('shipyard_provider') or 'EDSY')
+            )
+            # Setting to decide which ship outfitting website to link to - either E:D Shipyard or Coriolis
+            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.alt_shipyard_open_btn.grid(row=31, column=2, sticky=tk.W)
+            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.getint('use_alt_shipyard_open'))
+            self.alt_shipyard_open_btn = nb.Checkbutton(
+                config_frame,
+                text=_('Use alternate URL method'),
+                variable=self.alt_shipyard_open,
+                command=self.alt_shipyard_open_changed,
+            )
 
-        system_provider = config.get('system_provider')
-        self.system_provider = tk.StringVar(
-            value=str(system_provider if system_provider in plug.provides('system_url') else 'EDSM')
-        )
+            self.alt_shipyard_open_btn.grid(column=2, sticky=tk.W, row=cur_row)
 
-        nb.Label(configframe, text=_('System')).grid(row=32, padx=self.PADX, pady=2*self.PADY, sticky=tk.W)
-        self.system_button = nb.OptionMenu(
-            configframe,
-            self.system_provider,
-            self.system_provider.get(),
-            *plug.provides('system_url')
-        )
+        with row as cur_row:
+            system_provider = config.get('system_provider')
+            self.system_provider = tk.StringVar(
+                value=str(system_provider if system_provider in plug.provides('system_url') else 'EDSM')
+            )
 
-        self.system_button.configure(width=15)
-        self.system_button.grid(row=32, column=1, sticky=tk.W)
+            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')
+            )
 
-        station_provider = config.get('station_provider')
-        self.station_provider = tk.StringVar(
-            value=str(station_provider if station_provider in plug.provides('station_url') else 'eddb')
-        )
+            self.system_button.configure(width=15)
+            self.system_button.grid(column=1, sticky=tk.W, row=cur_row)
 
-        nb.Label(configframe, text=_('Station')).grid(row=33, padx=self.PADX, pady=2*self.PADY, sticky=tk.W)
-        self.station_button = nb.OptionMenu(
-            configframe,
-            self.station_provider,
-            self.station_provider.get(),
-            *plug.provides('station_url')
-        )
+        with row as cur_row:
+            station_provider = config.get('station_provider')
+            self.station_provider = tk.StringVar(
+                value=str(station_provider if station_provider in plug.provides('station_url') else 'eddb')
+            )
 
-        self.station_button.configure(width=15)
-        self.station_button.grid(row=33, column=1, sticky=tk.W)
+            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(configframe, orient=tk.HORIZONTAL).grid(columnspan=4, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW)
-
-        # Set the current loglevel
-        nb.Label(
-            configframe,
-            text=_('Log Level')
-        ).grid(row=35, padx=self.PADX, pady=2*self.PADY, sticky=tk.W)
-
-        current_loglevel = config.get('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))
+        ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid(
+            columnspan=4, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW, row=row.get()
         )
 
-        self.loglevel_dropdown = nb.OptionMenu(
-            configframe,
-            self.select_loglevel,
-            self.select_loglevel.get(),
-            *loglevels
-        )
+        with row as cur_row:
+            # Set the current loglevel
+            nb.Label(
+                config_frame,
+                text=_('Log Level')
+            ).grid(padx=self.PADX, pady=2*self.PADY, sticky=tk.W, row=cur_row)
 
-        self.loglevel_dropdown.configure(width=15)
-        self.loglevel_dropdown.grid(row=35, column=1, sticky=tk.W)
+            current_loglevel = config.get('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(configframe).grid(sticky=tk.W)
+        nb.Label(config_frame).grid(sticky=tk.W, row=row.get())
 
-        notebook.add(configframe, text=_('Configuration'))  # Tab heading in settings
+        notebook.add(config_frame, text=_('Configuration'))  # Tab heading in settings
 
+    def __setup_appearance_tab(self, notebook: Notebook) -> None:
         self.languages = Translations.available_names()
         # Appearance theme and language setting
         self.lang = tk.StringVar(value=self.languages.get(config.get('language'), _('Default')))
@@ -593,50 +648,65 @@ class PreferencesDialog(tk.Toplevel):
             _('Highlighted text'),  # Dark theme color setting
         ]
 
-    def __setup_theme_tab(self, notebook: Notebook) -> None:
-        themeframe = nb.Frame(notebook)
-        themeframe.columnconfigure(2, weight=1)
-        nb.Label(themeframe, text=_('Language')).grid(row=10, padx=self.PADX, sticky=tk.W)  # Appearance setting prompt
-        self.lang_button = nb.OptionMenu(themeframe, self.lang, self.lang.get(), *self.languages.values())
-        self.lang_button.grid(row=10, column=1, columnspan=2, padx=self.PADX, sticky=tk.W)
-        ttk.Separator(themeframe, orient=tk.HORIZONTAL).grid(columnspan=3, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW)
-        nb.Label(themeframe, text=_('Theme')).grid(columnspan=3, padx=self.PADX, sticky=tk.W)  # Appearance setting
-        nb.Radiobutton(themeframe, text=_('Default'), variable=self.theme, value=0, command=self.themevarchanged).grid(
-            columnspan=3, padx=self.BUTTONX, sticky=tk.W)  # Appearance theme and language setting
-        nb.Radiobutton(themeframe, text=_('Dark'), variable=self.theme, value=1, command=self.themevarchanged).grid(
-            columnspan=3, padx=self.BUTTONX, sticky=tk.W)  # Appearance theme setting
+        row = AutoInc(start=1)
+
+        appearance_frame = nb.Frame(notebook)
+        appearance_frame.columnconfigure(2, weight=1)
+        with row as cur_row:
+            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
+        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(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(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 platform == 'win32':
             nb.Radiobutton(
-                themeframe,
+                appearance_frame,
                 text=_('Transparent'),  # Appearance theme setting
                 variable=self.theme,
                 value=2,
                 command=self.themevarchanged
-            ).grid(columnspan=3, padx=self.BUTTONX, sticky=tk.W)
+            ).grid(columnspan=3, padx=self.BUTTONX, sticky=tk.W, row=row.get())
 
-        self.theme_label_0 = nb.Label(themeframe, text=self.theme_prompts[0])
-        self.theme_label_0.grid(row=20, padx=self.PADX, sticky=tk.W)
+        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(
-            themeframe,
-            text=_('Station'),
-            background='grey4',
-            command=lambda: self.themecolorbrowse(0)
-        )
+            # Main window
+            self.theme_button_0 = nb.ColoredButton(
+                appearance_frame,
+                text=_('Station'),
+                background='grey4',
+                command=lambda: self.themecolorbrowse(0)
+            )
 
-        self.theme_button_0.grid(row=20, column=1, padx=self.PADX, pady=self.PADY, sticky=tk.NSEW)
-        self.theme_label_1 = nb.Label(themeframe, text=self.theme_prompts[1])
-        self.theme_label_1.grid(row=21, padx=self.PADX, sticky=tk.W)
-        self.theme_button_1 = nb.ColoredButton(
-            themeframe,
-            text='  Hutton Orbital  ',  # Do not translate
-            background='grey4',
-            command=lambda: self.themecolorbrowse(1)
-        )
+            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(row=21, column=1, padx=self.PADX, pady=self.PADY, sticky=tk.NSEW)
+            self.theme_button_1.grid(column=1, padx=self.PADX, pady=self.PADY, sticky=tk.NSEW, row=cur_row)
 
         # UI Scaling
         """
@@ -646,96 +716,110 @@ class PreferencesDialog(tk.Toplevel):
         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(themeframe, orient=tk.HORIZONTAL).grid(columnspan=4, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW)
-        nb.Label(themeframe, text=_('UI Scale Percentage')).grid(row=23, padx=self.PADX, pady=2*self.PADY, sticky=tk.W)
-        self.ui_scale = tk.IntVar()
-        self.ui_scale.set(config.getint('ui_scale'))
-        self.uiscale_bar = tk.Scale(
-            themeframe,
-            variable=self.ui_scale,  # TODO: intvar, but annotated as DoubleVar
-            orient=tk.HORIZONTAL,
-            length=300 * (float(theme.startup_ui_scale) / 100.0 * theme.default_ui_scale),
-            from_=0,
-            to=400,
-            tickinterval=50,
-            resolution=10,
+        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:
+            nb.Label(appearance_frame, text=_('UI Scale Percentage')).grid(
+                padx=self.PADX, pady=2*self.PADY, sticky=tk.W, row=cur_row
+            )
 
-        self.uiscale_bar.grid(row=23, column=1, sticky=tk.W)
-        self.ui_scaling_defaultis = nb.Label(
-            themeframe,
-            text=_('100 means Default{CR}Restart Required for{CR}changes to take effect!')
-        ).grid(row=23, column=3, padx=PADX, pady=2*PADY, sticky=tk.E)
+            self.ui_scale = tk.IntVar()
+            self.ui_scale.set(config.getint('ui_scale'))
+            self.uiscale_bar = tk.Scale(
+                appearance_frame,
+                variable=self.ui_scale,  # 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,
+                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)
 
         # Always on top
-        ttk.Separator(themeframe, orient=tk.HORIZONTAL).grid(columnspan=3, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW)
+        ttk.Separator(appearance_frame, orient=tk.HORIZONTAL).grid(
+            columnspan=3, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW, row=row.get()
+        )
+
         self.ontop_button = nb.Checkbutton(
-            themeframe,
+            appearance_frame,
             text=_('Always on top'),
             variable=self.always_ontop,
             command=self.themevarchanged
         )
+        self.ontop_button.grid(columnspan=3, padx=self.BUTTONX, sticky=tk.W, row=cur_row)  # Appearance setting
+        
+        nb.Label(appearance_frame).grid(sticky=tk.W)  # big spacer
 
-        self.ontop_button.grid(columnspan=3, padx=self.BUTTONX, sticky=tk.W)  # Appearance setting
-        nb.Label(themeframe).grid(sticky=tk.W)  # big spacer
-
-        notebook.add(themeframe, text=_('Appearance'))  # Tab heading in settings
+        notebook.add(appearance_frame, text=_('Appearance'))  # Tab heading in settings
 
     def __setup_plugin_tab(self, notebook: Notebook) -> None:
         # Plugin tab itself
         # Plugin settings and info
-        plugsframe = nb.Frame(notebook)
-        plugsframe.columnconfigure(0, weight=1)
+        plugins_frame = nb.Frame(notebook)
+        plugins_frame.columnconfigure(0, weight=1)
         plugdir = tk.StringVar()
         plugdir.set(config.plugin_dir)
+        row = AutoInc(1)
 
-        nb.Label(plugsframe, text=_('Plugins folder')+':').grid(padx=self.PADX, sticky=tk.W)  # Section heading in settings
-        plugdirentry = nb.Entry(plugsframe, justify=tk.LEFT)
+        # Section heading in settings
+        nb.Label(plugins_frame, text=_('Plugins folder')+':').grid(padx=self.PADX, sticky=tk.W)
+        plugdirentry = nb.Entry(plugins_frame, justify=tk.LEFT)
         self.displaypath(plugdir, plugdirentry)
-        plugdirentry.grid(row=10, padx=self.PADX, sticky=tk.EW)
+        with row as cur_row:
+            plugdirentry.grid(padx=self.PADX, sticky=tk.EW, row=cur_row)
 
-        nb.Button(
-            plugsframe,
-            text=_('Open'),  # Button that opens a folder in Explorer/Finder
-            command=lambda: webbrowser.open(f'file:///{plugdir.get()}')
-        ).grid(row=10, column=1, padx=(0, self.PADX), sticky=tk.NSEW)
+            nb.Button(
+                plugins_frame,
+                text=_('Open'),  # Button that opens a folder in Explorer/Finder
+                command=lambda: webbrowser.open(f'file:///{plugdir.get()}')
+            ).grid(column=1, padx=(0, self.PADX), sticky=tk.NSEW, row=cur_row)
 
         nb.Label(
-            plugsframe,
+            plugins_frame,
             # Help text in settings
             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)
+        ).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(plugsframe, orient=tk.HORIZONTAL).grid(columnspan=3, padx=self.PADX, pady=self.PADY * 8, sticky=tk.EW)
+            ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(columnspan=3, padx=self.PADX, pady=self.PADY * 8, sticky=tk.EW)
             nb.Label(
-                plugsframe,
+                plugins_frame,
                 text=_('Enabled Plugins')+':'  # List of plugins in settings
-            ).grid(padx=self.PADX, sticky=tk.W)
+            ).grid(padx=self.PADX, sticky=tk.W, row=row.get())
 
             for plugin in enabled_plugins:
                 if plugin.name == plugin.folder:
-                    label = nb.Label(plugsframe, text=plugin.name)
+                    label = nb.Label(plugins_frame, text=plugin.name)
 
                 else:
-                    label = nb.Label(plugsframe, text=f'{plugin.folder} ({plugin.name})')
+                    label = nb.Label(plugins_frame, text=f'{plugin.folder} ({plugin.name})')
 
-                label.grid(columnspan=2, padx=self.PADX*2, sticky=tk.W)
+                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(plugsframe, orient=tk.HORIZONTAL).grid(columnspan=3, padx=self.PADX, pady=self.PADY * 8, sticky=tk.EW)
-            nb.Label(plugsframe, text=_('Plugins Without Python 3.x Support:')+':').grid(padx=self.PADX, sticky=tk.W)
+            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, 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(plugsframe, text=plugin.name).grid(columnspan=2, padx=self.PADX*2, sticky=tk.W)
+                    nb.Label(plugins_frame, text=plugin.name).grid(columnspan=2, padx=self.PADX*2, sticky=tk.W)
 
             HyperlinkLabel(
-                plugsframe, text=_('Information on migrating plugins'),
+                plugins_frame, text=_('Information on migrating plugins'),
                 background=nb.Label().cget('background'),
                 url='https://github.com/EDCD/EDMarketConnector/blob/main/PLUGINS.md#migration-to-python-37',
                 underline=True
@@ -744,16 +828,20 @@ class PreferencesDialog(tk.Toplevel):
 
         disabled_plugins = list(filter(lambda x: x.folder and not x.module, plug.PLUGINS))
         if len(disabled_plugins):
-            ttk.Separator(plugsframe, orient=tk.HORIZONTAL).grid(columnspan=3, padx=self.PADX, pady=self.PADY * 8, sticky=tk.EW)
+            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(
-                plugsframe,
+                plugins_frame,
                 text=_('Disabled Plugins')+':'  # List of plugins in settings
-            ).grid(padx=self.PADX, sticky=tk.W)
+            ).grid(padx=self.PADX, sticky=tk.W, row=row.get())
 
             for plugin in disabled_plugins:
-                nb.Label(plugsframe, text=plugin.name).grid(columnspan=2, padx=self.PADX*2, sticky=tk.W)
+                nb.Label(plugins_frame, text=plugin.name).grid(
+                    columnspan=2, padx=self.PADX*2, sticky=tk.W, row=row.get()
+                )
 
-        notebook.add(plugsframe, text=_('Plugins'))		# Tab heading in settings
+        notebook.add(plugins_frame, text=_('Plugins'))		# Tab heading in settings
 
     def cmdrchanged(self, event=None):
         """