"""EDMC preferences library.""" from __future__ import annotations import logging import time import os from pathlib import Path import subprocess import sys import tkinter as tk from tkinter import colorchooser as tkColorChooser # type: ignore # noqa: N812 from tkinter import ttk from itertools import count from typing import Any from collections.abc import Callable from functools import partial import plugin_browser import myNotebook as nb # noqa: N813 import plug from config import appversion_nobuild, config, IS_FROZEN from EDMCLogging import edmclogger, get_main_logger from hotkey import hotkeymgr from l10n import translations as tr from monitor import monitor from theme import theme from ttkHyperlinkLabel import HyperlinkLabel from common_utils import ensure_on_screen import commodity logger = get_main_logger() # TODO: Decouple this from platform as far as possible ########################################################################### # Versioned preferences, so we know whether to set an 'on' default on # 'new' preferences, or not. ########################################################################### # May be imported by plugins def open_folder(file: Path) -> None: """Open the given file in the OS file explorer.""" # Ensure we are working with a resolved Path object file_path = Path(file).resolve() if sys.platform.startswith('win'): # On Windows, use the "start" command to open the folder subprocess.run(['start', '', file_path], shell=True) elif sys.platform.startswith('linux'): # On Linux, use the "xdg-open" command to open the folder subprocess.run(['xdg-open', str(file_path)]) def help_open_system_profiler(parent) -> None: """Open the EDMC System Profiler.""" if IS_FROZEN: cmd = [str(Path(config.respath_path) / 'EDMCSystemProfiler.exe')] else: cmd = [sys.executable, str(Path(config.respath_path) / "EDMCSystemProfiler.py")] try: subprocess.Popen(cmd, shell=False) except Exception as err: parent.status["text"] = tr.tl("Error in System Profiler") # LANG: Catch & Record Profiler Errors logger.exception(f"Failed to launch System Profiler: {err}") class PrefsVersion: """ PrefsVersion contains versioned preferences. It allows new defaults to be set as they are added if they are found to be missing """ versions = { '0.0.0.0': 1, '1.0.0.0': 2, '3.4.6.0': 3, '3.5.1.0': 4, # Only add new versions that add new Preferences # Should always match the last specific version, but only increment after you've added the new version. # Guess at it if anticipating a new version. 'current': 4, } def __init__(self): return def stringToSerial(self, versionStr: str) -> int: # noqa: N802, N803 # used in plugins """ Convert a version string into a preferences version serial number. If the version string isn't known returns the 'current' (latest) serial number. :param versionStr: :return int: """ if versionStr in self.versions: return self.versions[versionStr] return self.versions['current'] def shouldSetDefaults(self, addedAfter: str, oldTest: bool = True) -> bool: # noqa: N802,N803 # used in plugins """ Whether or not defaults should be set if they were added after the specified version. :param addedAfter: The version after which these settings were added :param oldTest: Default, if we have no current settings version, defaults to True :raises ValueError: on serial number after the current latest :return: bool indicating the answer """ # config.get('PrefsVersion') is the version preferences we last saved for pv = config.get_int('PrefsVersion') # If no PrefsVersion yet exists then return oldTest if not pv: return oldTest # Convert addedAfter to a version serial number if addedAfter not in self.versions: # Assume it was added at the start aa = 1 else: aa = self.versions[addedAfter] # Sanity check, if something was added after then current should be greater if aa >= self.versions['current']: raise ValueError( 'ERROR: Call to prefs.py:PrefsVersion.shouldSetDefaults() with ' '"addedAfter" >= current latest in "versions" table.' ' You probably need to increase "current" serial number.' ) # If this preference was added after the saved PrefsVersion we should set defaults if aa >= pv: return True return False prefsVersion = PrefsVersion() # noqa: N816 # Cannot rename as used in plugins if sys.platform == 'win32': import winreg import win32api from win32com.shell import shell, shellcon import pythoncom is_wine = False try: WINE_REGISTRY_KEY = r'HKEY_LOCAL_MACHINE\Software\Wine' reg = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) winreg.OpenKey(reg, WINE_REGISTRY_KEY) is_wine = True except OSError: # Assumed to be 'path not found', i.e. not-wine pass def get_localized_path(path: str, config_home: str) -> str: """ Get the localized display path for a Windows file system path. Shows only drive letters without labels. Args: path: The path to localize config_home: The home directory path Returns: Localized path as string """ # noqa: D205, D407 if not path: return path # Determine start index based on if path starts with home directory start_idx = len(config_home.split('\\')) if path.lower().startswith(config_home.lower()) else 0 # Split path into components components = Path(path).parts display_components = [] # Process each path component for i in range(start_idx, len(components)): current_path = '\\'.join(components[:i + 1]) # Handle drive letter specially if i == 0 and ':' in components[i]: display_components.append(components[i]) continue try: # Get localized name info using shell API pidl = shell.SHParseDisplayName(current_path, 0)[0] name_info = shell.SHGetNameFromIDList(pidl, shellcon.SIGDN_NORMALDISPLAY) if name_info: display_components.append(name_info) else: display_components.append(components[i]) except (pythoncom.com_error, win32api.error): # Fall back to original component name if localization fails display_components.append(components[i]) return '\\'.join(display_components) class PreferencesDialog(tk.Toplevel, plugin_browser.PluginBrowserMixIn): """The EDMC preferences dialog.""" def __init__(self, parent: tk.Tk, callback: Callable | None): super().__init__(parent) self.parent = parent self.callback = callback self.req_restart = False # LANG: File > Settings (macOS) self.title(tr.tl('Settings')) if parent.winfo_viewable(): self.transient(parent) # Position over parent self.geometry(f'+{parent.winfo_rootx()}+{parent.winfo_rooty()}') # Remove decoration if sys.platform == 'win32': self.attributes('-toolwindow', tk.TRUE) # Allow the window to be resizable self.resizable(tk.TRUE, tk.TRUE) self.cmdr: str | bool | None = False # Note if Cmdr changes in the Journal self.is_beta: bool = False # Note if Beta status changes in the Journal self.cmdrchanged_alarm: str | None = None # This stores an ID that can be used to cancel a scheduled call # Set up the main frame frame = ttk.Frame(self) frame.grid(sticky=tk.NSEW) self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) frame.columnconfigure(0, weight=1) frame.rowconfigure(0, weight=1) frame.rowconfigure(1, weight=0) notebook: nb.ScrollableNotebook = nb.ScrollableNotebook(frame, tabmenu=True, on_tab_change=self.tabchanged) self.PADX = 10 self.BUTTONX = 12 # indent Checkbuttons and Radiobuttons self.LISTX = 25 # indent listed items self.PADY = 1 # close spacing self.BOXY = 2 # box spacing self.SEPY = 10 # separator line spacing # Set up different tabs self.__setup_config_tab(notebook) self.__setup_appearance_tab(notebook) self.__setup_output_tab(notebook) self.__setup_privacy_tab(notebook) self.__setup_plugin_tab(notebook) self.setup_browser_tab(notebook, count()) self.__setup_plugin_tabs(notebook) # Set up the button frame buttonframe = ttk.Frame(frame) buttonframe.grid(padx=self.PADX, pady=self.PADX, sticky=tk.NSEW) buttonframe.columnconfigure(0, weight=1) ttk.Label(buttonframe).grid(row=0, column=0) # spacer # LANG: 'OK' button on Settings/Preferences window button = ttk.Button(buttonframe, text=tr.tl('OK'), command=self.apply) button.grid(row=0, column=1, sticky=tk.E) button.bind("", lambda event: self.apply()) self.protocol("WM_DELETE_WINDOW", self._destroy) # FIXME: Why are these being called when *creating* the Settings window? # Selectively disable buttons depending on output settings self.cmdrchanged() self.themevarchanged() # Init hotkey attributes to avoid linter errors self.hotkey_text: ttk.Entry self.hotkey_only_btn: nb.Checkbutton self.hotkey_play_btn: nb.Checkbutton # disable hotkey for the duration hotkeymgr.unregister() # wait for window to appear on screen before calling grab_set self.parent.update_idletasks() self.parent.wm_attributes('-topmost', 0) # needed for dialog to appear on top of parent on Linux self.wait_visibility() self.grab_set() # Ensure fully on-screen ensure_on_screen(self, parent) # Set Log Directory self.logfile_loc = Path(config.app_dir_path / 'logs') def __setup_output_tab(self, root_notebook: ttk.Notebook) -> None: output_frame = nb.Frame(root_notebook) output_frame.columnconfigure(0, weight=1) if prefsVersion.shouldSetDefaults('0.0.0.0', not bool(config.get_int('output'))): output = config.OUT_SHIP # default settings else: output = config.get_int('output') row = count() # LANG: Settings > Output - choosing what data to save to files self.out_label = nb.Label(output_frame, text=tr.tl('Please choose what data to save')) self.out_label.grid(columnspan=2, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=next(row)) self.out_csv = tk.IntVar(value=1 if (output & config.OUT_MKT_CSV) else 0) self.out_csv_button = nb.Checkbutton( output_frame, text=tr.tl('Market data in CSV format file'), # LANG: Settings > Output option variable=self.out_csv, command=self.outvarchanged ) self.out_csv_button.grid(columnspan=2, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row)) # Export format selection for market text exports # Group the Label and the ComboBox for consistent layout reflow mkt_frame = nb.Frame(output_frame) # type: ignore # add increment row for the sub-frame mkt_frame.grid(padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=next(row)) self.mkt_export_type = tk.StringVar() # Default to SEMICOLON for backward compatibility; normalise any saved value _mkt_saved = config.get_str('mkt_export_type', default='SEMICOLON') or 'SEMICOLON' _mkt_saved = _mkt_saved.upper() if _mkt_saved not in commodity.mkt_out_types: # ('CSV', 'CSV_NEW', 'TAB', 'PIPE', 'SEMICOLON'): _mkt_saved = 'SEMICOLON' self.mkt_export_type.set(_mkt_saved) # LANG: Settings > Output - choose delimiter/format for market export files nb.Label(mkt_frame, text=tr.tl('Market export format:')).grid( padx=self.PADX, pady=self.PADY, sticky=tk.W, column=1, row=0 ) # Use a read-only combobox to present options self.mkt_export_combobox = ttk.Combobox( mkt_frame, textvariable=self.mkt_export_type, values=commodity.mkt_out_types, state='readonly', width=12 ) # self.mkt_export_combobox.grid(column=2, padx=self.PADX, pady=self.BOXY, sticky=tk.W, row=next(row)) self.mkt_export_combobox.grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, column=2, row=0) self.out_td = tk.IntVar(value=1 if (output & config.OUT_MKT_TD) else 0) self.out_td_button = nb.Checkbutton( output_frame, text=tr.tl('Market data in Trade Dangerous format file'), # LANG: Settings > Output option variable=self.out_td, command=self.outvarchanged ) self.out_td_button.grid(columnspan=2, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row)) self.out_ship = tk.IntVar(value=1 if (output & config.OUT_SHIP) else 0) # Output setting self.out_ship_button = nb.Checkbutton( output_frame, text=tr.tl('Ship loadout'), # LANG: Settings > Output option variable=self.out_ship, command=self.outvarchanged ) self.out_ship_button.grid(columnspan=2, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row)) self.out_auto = tk.IntVar(value=0 if output & config.OUT_MKT_MANUAL else 1) # inverted # Output setting self.out_auto_button = nb.Checkbutton( output_frame, text=tr.tl('Automatically update on docking'), # LANG: Settings > Output option variable=self.out_auto, command=self.outvarchanged ) self.out_auto_button.grid(columnspan=2, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row)) self.outdir = tk.StringVar() outdir_saved = str(config.get_str('outdir') or '') self.outdir.set(self.resolve_path_str(outdir_saved)) self.attach_path_resolver(self.outdir) # LANG: Settings > Output - Label for "where files are located" self.outdir_label = nb.Label(output_frame, text=tr.tl('File location') + ':') # Section heading in settings # Type ignored due to incorrect type annotation. a 2 tuple does padding for each side self.outdir_label.grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=next(row)) # type: ignore # Linked to self.outdir via textvariable and set takefocus=True to allow direct typing self.outdir_entry = ttk.Entry(output_frame, textvariable=self.outdir, takefocus=True) self.outdir_entry.grid(columnspan=2, padx=self.PADX, pady=self.BOXY, sticky=tk.EW, row=next(row)) text = tr.tl('Browse...') # LANG: NOT-macOS Settings - files location selection button self.outbutton = ttk.Button( output_frame, text=text, # Technically this is different from the label in Settings > Output, as *this* is used # as the title of the popup folder selection window. # LANG: Settings > Output - Label for "where files are located" command=lambda: self.filebrowse(tr.tl('File location'), self.outdir) ) self.outbutton.grid(column=1, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=next(row)) # LANG: Label for 'Output' Settings/Preferences tab root_notebook.add(output_frame, text=tr.tl('Output')) # Tab heading in settings def __setup_plugin_tabs(self, notebook: ttk.Notebook) -> None: for plugin in plug.PLUGINS: plugin_frame = plugin.get_prefs(notebook, monitor.cmdr, monitor.is_beta) if plugin_frame: notebook.add(plugin_frame, text=plugin.name) def __setup_config_tab(self, notebook: ttk.Notebook) -> None: # noqa: CCR001 config_frame = nb.Frame(notebook) config_frame.columnconfigure(1, weight=1) row = count() 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(self.resolve_path_str(logdir)) self.attach_path_resolver(self.logdir) # takefocus=True allows for typing self.logdir_entry = ttk.Entry(config_frame, textvariable=self.logdir, takefocus=True) # Location of the Journal files nb.Label( config_frame, # LANG: Settings > Configuration - Label for Journal files location text=tr.tl('E:D journal file location') + ':' ).grid(columnspan=4, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=next(row)) self.logdir_entry.grid(columnspan=4, padx=self.PADX, pady=self.BOXY, sticky=tk.EW, row=next(row)) text = tr.tl('Browse...') # LANG: NOT-macOS Setting - files location selection button cur_row = next(row) self.logbutton = ttk.Button( config_frame, text=text, # LANG: Settings > Configuration - Label for Journal files location command=lambda: self.filebrowse(tr.tl('E:D journal file location'), self.logdir) ) self.logbutton.grid(column=3, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row) if config.default_journal_dir_path: # Appearance theme and language setting ttk.Button( config_frame, # LANG: Settings > Configuration - Label on 'reset journal files location to default' button text=tr.tl('Default'), command=self.logdir_reset, state=tk.NORMAL if config.get_str('journaldir') else tk.DISABLED ).grid(column=2, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row) # CAPI settings self.capi_fleetcarrier = tk.BooleanVar(value=config.get_bool('capi_fleetcarrier')) ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid( columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) nb.Label( config_frame, text=tr.tl('CAPI Settings') # LANG: Settings > Configuration - Label for CAPI section ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=next(row)) nb.Checkbutton( config_frame, # LANG: Configuration - Enable or disable the Fleet Carrier CAPI calls text=tr.tl('Enable Fleet Carrier CAPI Queries'), variable=self.capi_fleetcarrier ).grid(columnspan=4, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row)) if sys.platform == 'win32': ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid( columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) 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')) cur_row = next(row) nb.Label( config_frame, text=tr.tl('Hotkey') # LANG: Hotkey/Shortcut settings prompt on Windows ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row) self.hotkey_text = ttk.Entry(config_frame, width=30, justify=tk.CENTER) self.hotkey_text.insert( 0, # No hotkey/shortcut currently defined # TODO: display Only shows up on windows # LANG: No hotkey/shortcut set hotkeymgr.display(self.hotkey_code, self.hotkey_mods) if self.hotkey_code else tr.tl('None') ) self.hotkey_text.bind('', self.hotkeystart) self.hotkey_text.bind('', self.hotkeyend) self.hotkey_text.grid(column=1, columnspan=2, pady=self.BOXY, sticky=tk.W, row=cur_row) # Hotkey/Shortcut setting self.hotkey_only_btn = nb.Checkbutton( config_frame, # LANG: Configuration - Act on hotkey only when ED is in foreground text=tr.tl('Only when Elite: Dangerous is the active app'), variable=self.hotkey_only, state=tk.NORMAL if self.hotkey_code else tk.DISABLED ) self.hotkey_only_btn.grid(columnspan=4, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row)) # Hotkey/Shortcut setting self.hotkey_play_btn = nb.Checkbutton( config_frame, # LANG: Configuration - play sound when hotkey used text=tr.tl('Play sound'), variable=self.hotkey_play, state=tk.NORMAL if self.hotkey_code else tk.DISABLED ) self.hotkey_play_btn.grid(columnspan=4, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row)) # Options to select the Update Path and Disable Automatic Checks For Updates whilst in-game ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid( columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) curr_row = next(row) nb.Label(config_frame, text=tr.tl('Update Track')).grid( # LANG: Select the Update Track (Beta, Stable) padx=self.PADX, pady=self.PADY, sticky=tk.W, row=curr_row ) self.curr_update_track = "Beta" if config.get_bool('beta_optin') else "Stable" self.update_paths = tk.StringVar(value=self.curr_update_track) update_paths = [ tr.tl("Stable"), # LANG: Stable Version of EDMC tr.tl("Beta") # LANG: Beta Version of EDMC ] self.update_track = nb.OptionMenu( config_frame, self.update_paths, self.update_paths.get(), *update_paths ) self.update_track.configure(width=15) self.update_track.grid(column=1, pady=self.BOXY, padx=self.PADX, sticky=tk.W, row=curr_row) self.disable_autoappupdatecheckingame = tk.IntVar(value=config.get_int('disable_autoappupdatecheckingame')) self.disable_autoappupdatecheckingame_btn = nb.Checkbutton( config_frame, # LANG: Configuration - disable checks for app updates when in-game text=tr.tl('Disable Automatic Application Updates Check when in-game'), variable=self.disable_autoappupdatecheckingame, command=self.disable_autoappupdatecheckingame_changed ) self.disable_autoappupdatecheckingame_btn.grid( columnspan=4, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row) ) ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid( columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) # Settings prompt for preferred ship loadout, system and station info websites # LANG: Label for preferred shipyard, system and station 'providers' nb.Label(config_frame, text=tr.tl('Preferred websites')).grid( columnspan=4, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=next(row) ) cur_row = next(row) shipyard_provider = config.get_str('shipyard_provider') self.shipyard_provider = tk.StringVar( value=str(shipyard_provider if shipyard_provider in plug.provides('shipyard_url') else 'EDSY') ) # Setting to decide which ship outfitting website to link to - either E:D Shipyard or Coriolis # LANG: Label for Shipyard provider selection nb.Label(config_frame, text=tr.tl('Shipyard')).grid( padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row ) self.shipyard_button = nb.OptionMenu( config_frame, self.shipyard_provider, self.shipyard_provider.get(), *plug.provides('shipyard_url') ) self.shipyard_button.configure(width=15) self.shipyard_button.grid(column=1, pady=self.BOXY, sticky=tk.W, row=cur_row) # Option for alternate URL opening self.alt_shipyard_open = tk.IntVar(value=config.get_int('use_alt_shipyard_open')) self.alt_shipyard_open_btn = nb.Checkbutton( config_frame, # LANG: Label for checkbox to utilise alternative Coriolis URL method text=tr.tl('Use alternate URL method'), variable=self.alt_shipyard_open, command=self.alt_shipyard_open_changed, ) self.alt_shipyard_open_btn.grid(column=2, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row) cur_row = next(row) system_provider = config.get_str('system_provider') self.system_provider = tk.StringVar( value=str(system_provider if system_provider in plug.provides('system_url') else 'EDSM') ) # LANG: Configuration - Label for selection of 'System' provider website nb.Label(config_frame, text=tr.tl('System')).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row) self.system_button = nb.OptionMenu( config_frame, self.system_provider, self.system_provider.get(), *plug.provides('system_url') ) self.system_button.configure(width=15) self.system_button.grid(column=1, pady=self.BOXY, sticky=tk.W, row=cur_row) cur_row = next(row) station_provider = config.get_str('station_provider') self.station_provider = tk.StringVar( value=str(station_provider if station_provider in plug.provides('station_url') else 'EDSM') ) # LANG: Configuration - Label for selection of 'Station' provider website nb.Label(config_frame, text=tr.tl('Station')).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row) self.station_button = nb.OptionMenu( config_frame, self.station_provider, self.station_provider.get(), *plug.provides('station_url') ) self.station_button.configure(width=15) self.station_button.grid(column=1, pady=self.BOXY, sticky=tk.W, row=cur_row) # Set loglevel ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid( columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) cur_row = next(row) # Set the current loglevel nb.Label( config_frame, # LANG: Configuration - Label for selection of Log Level text=tr.tl('Log Level') ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row) current_loglevel = config.get_str('loglevel') if not current_loglevel: current_loglevel = logging.getLevelName(logging.INFO) self.select_loglevel = tk.StringVar(value=str(current_loglevel)) loglevels = list( map(logging.getLevelName, ( logging.CRITICAL, logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG )) ) self.loglevel_dropdown = nb.OptionMenu( config_frame, self.select_loglevel, self.select_loglevel.get(), *loglevels ) self.loglevel_dropdown.configure(width=15) self.loglevel_dropdown.grid(column=1, pady=self.BOXY, sticky=tk.W, row=cur_row) ttk.Button( config_frame, # LANG: Label on button used to open a filesystem folder text=tr.tl('Open Log Folder'), # Button that opens a folder in Explorer/Finder command=lambda: open_folder(self.logfile_loc) ).grid(column=2, padx=self.PADX, pady=0, sticky=tk.NSEW, row=cur_row) # Big spacer nb.Label(config_frame).grid(sticky=tk.W, row=next(row)) # LANG: Label for 'Configuration' tab in Settings notebook.add(config_frame, text=tr.tl('Configuration')) def __setup_privacy_tab(self, notebook: ttk.Notebook) -> None: privacy_frame = nb.Frame(notebook) self.hide_multicrew_captain = tk.BooleanVar(value=config.get_bool('hide_multicrew_captain', default=False)) self.hide_private_group = tk.BooleanVar(value=config.get_bool('hide_private_group', default=False)) row = count() # LANG: UI elements privacy section header in privacy tab of preferences nb.Label(privacy_frame, text=tr.tl('Main UI privacy options')).grid( row=next(row), column=0, sticky=tk.W, padx=self.PADX, pady=self.PADY ) nb.Checkbutton( # LANG: Hide private group owner name from UI checkbox privacy_frame, text=tr.tl('Hide private group name in UI'), variable=self.hide_private_group ).grid(row=next(row), column=0, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W) nb.Checkbutton( # LANG: Hide multicrew captain name from main UI checkbox privacy_frame, text=tr.tl('Hide multi-crew captain name'), variable=self.hide_multicrew_captain ).grid(row=next(row), column=0, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W) notebook.add(privacy_frame, text=tr.tl('Privacy')) # LANG: Preferences privacy tab title def __setup_appearance_tab(self, notebook: ttk.Notebook) -> None: self.languages = tr.available_names() # Appearance theme and language setting # LANG: The system default language choice in Settings > Appearance self.lang = tk.StringVar(value=self.languages.get(config.get_str('language'), tr.tl('Default'))) self.always_ontop = tk.BooleanVar(value=config.get_bool('always_ontop')) self.minimize_system_tray = tk.BooleanVar(value=config.get_bool('minimize_system_tray')) self.disable_system_tray = tk.BooleanVar(value=config.get_bool('no_systray')) self.theme = tk.IntVar(value=config.get_int('theme')) self.theme_colors = [config.get_str('dark_text'), config.get_str('dark_highlight')] self.theme_prompts = [ # LANG: Label for Settings > Appeareance > selection of 'normal' text colour tr.tl('Normal text'), # Dark theme color setting # LANG: Label for Settings > Appeareance > selection of 'highlightes' text colour tr.tl('Highlighted text'), # Dark theme color setting ] row = count() appearance_frame = nb.Frame(notebook) appearance_frame.columnconfigure(2, weight=1) cur_row = next(row) # LANG: Appearance - Label for selection of application display language nb.Label(appearance_frame, text=tr.tl('Language')).grid( padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row ) self.lang_button = nb.OptionMenu(appearance_frame, self.lang, self.lang.get(), *self.languages.values()) self.lang_button.grid(column=1, columnspan=2, padx=0, pady=self.BOXY, sticky=tk.W, row=cur_row) ttk.Separator(appearance_frame, orient=tk.HORIZONTAL).grid( columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) # Appearance setting # LANG: Label for Settings > Appearance > Theme selection nb.Label(appearance_frame, text=tr.tl('Theme')).grid( columnspan=3, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=next(row) ) # Appearance theme and language setting nb.Radiobutton( # LANG: Label for 'Default' theme radio button appearance_frame, text=tr.tl('Default'), variable=self.theme, value=theme.THEME_DEFAULT, command=self.themevarchanged ).grid(columnspan=3, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row)) # Appearance theme setting nb.Radiobutton( # LANG: Label for 'Dark' theme radio button appearance_frame, text=tr.tl('Dark'), variable=self.theme, value=theme.THEME_DARK, command=self.themevarchanged ).grid(columnspan=3, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row)) if sys.platform == 'win32': nb.Radiobutton( appearance_frame, # LANG: Label for 'Transparent' theme radio button text=tr.tl('Transparent'), # Appearance theme setting variable=self.theme, value=theme.THEME_TRANSPARENT, command=self.themevarchanged ).grid(columnspan=3, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row)) cur_row = next(row) self.theme_label_0 = nb.Label(appearance_frame, text=self.theme_prompts[0]) self.theme_label_0.grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row) # Main window self.theme_button_0 = tk.Button( appearance_frame, # LANG: Appearance - Example 'Normal' text text=tr.tl('Station'), background='grey4', command=lambda: self.themecolorbrowse(0) ) self.theme_button_0.grid(column=1, padx=0, pady=self.BOXY, sticky=tk.NSEW, row=cur_row) cur_row = next(row) self.theme_label_1 = nb.Label(appearance_frame, text=self.theme_prompts[1]) self.theme_label_1.grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row) self.theme_button_1 = tk.Button( appearance_frame, text=' Hutton Orbital ', # Do not translate background='grey4', command=lambda: self.themecolorbrowse(1) ) self.theme_button_1.grid(column=1, padx=0, pady=self.BOXY, sticky=tk.NSEW, row=cur_row) # UI Scaling """ The provided UI Scale setting is a percentage value relative to the tk-scaling setting on startup. So, if at startup we find tk-scaling is 1.33 and have a user setting of 200 we'll end up setting 2.66 as the tk-scaling value. """ ttk.Separator(appearance_frame, orient=tk.HORIZONTAL).grid( columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) cur_row = next(row) # LANG: Appearance - Label for selection of UI scaling nb.Label(appearance_frame, text=tr.tl('UI Scale Percentage')).grid( padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row ) self.ui_scale = tk.IntVar() self.ui_scale.set(config.get_int('ui_scale')) self.uiscale_bar = tk.Scale( appearance_frame, variable=self.ui_scale, # type: ignore # TODO: intvar, but annotated as DoubleVar orient=tk.HORIZONTAL, length=300 * (float(theme.startup_ui_scale) / 100.0 * theme.default_ui_scale), # type: ignore # runtime from_=0, to=400, tickinterval=50, resolution=10, ) self.uiscale_bar.grid(column=1, padx=0, pady=self.BOXY, sticky=tk.W, row=cur_row) self.ui_scaling_defaultis = nb.Label( appearance_frame, # LANG: Appearance - Help/hint text for UI scaling selection text=tr.tl('100 means Default{CR}Restart Required for{CR}changes to take effect!') ) # E1111 self.ui_scaling_defaultis.grid(column=3, padx=self.PADX, pady=self.PADY, sticky=tk.E, row=cur_row) # Transparency slider ttk.Separator(appearance_frame, orient=tk.HORIZONTAL).grid( columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) cur_row = next(row) # LANG: Appearance - Label for selection of main window transparency nb.Label(appearance_frame, text=tr.tl("Main window transparency")).grid( padx=self.PADX, pady=self.PADY, sticky=tk.W, row=cur_row ) self.transparency = tk.IntVar() self.transparency.set(config.get_int('ui_transparency') or 100) # Default to 100 for users self.transparency_bar = tk.Scale( appearance_frame, variable=self.transparency, # type: ignore # Its accepted as an intvar orient=tk.HORIZONTAL, length=300 * (float(theme.startup_ui_scale) / 100.0 * theme.default_ui_scale), # type: ignore # runtime from_=100, to=5, tickinterval=10, resolution=5, command=lambda _: self.parent.wm_attributes("-alpha", self.transparency.get() / 100) ) nb.Label( appearance_frame, # LANG: Appearance - Help/hint text for Main window transparency selection text=tr.tl( "100 means fully opaque.{CR}" "Window is updated in real time" ).format(CR='\n') ).grid( column=3, padx=self.PADX, pady=self.PADY, sticky=tk.E, row=cur_row ) self.transparency_bar.grid(column=1, padx=0, pady=self.BOXY, sticky=tk.W, row=cur_row) # Always on top ttk.Separator(appearance_frame, orient=tk.HORIZONTAL).grid( columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) self.ontop_button = nb.Checkbutton( appearance_frame, # LANG: Appearance - Label for checkbox to select if application always on top text=tr.tl('Always on top'), variable=self.always_ontop, command=self.themevarchanged ) self.ontop_button.grid( columnspan=3, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row) ) # Appearance setting if sys.platform == 'win32' and not config.get_bool('no_systray'): nb.Checkbutton( appearance_frame, # LANG: Appearance option for Windows "minimize to system tray" text=tr.tl('Minimize to system tray'), variable=self.minimize_system_tray, command=self.themevarchanged ).grid(columnspan=3, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row)) # Appearance setting if sys.platform == 'win32': nb.Checkbutton( appearance_frame, # LANG: Appearance option for Windows "Disable Systray" text=tr.tl('Disable Systray'), variable=self.disable_system_tray, command=self.themevarchanged, ).grid(columnspan=3, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=next(row)) # Appearance setting nb.Label(appearance_frame).grid(sticky=tk.W) # big spacer # LANG: Label for Settings > Appearance tab notebook.add(appearance_frame, text=tr.tl('Appearance')) # Tab heading in settings def __setup_plugin_tab(self, notebook: ttk.Notebook) -> None: # noqa: CCR001 # Plugin settings and info plugins_frame = nb.Frame(notebook) plugins_frame.columnconfigure(0, weight=1) row = count() self.plugdir = tk.StringVar() self.plugdir.set(str(config.get_str('plugin_dir'))) # LANG: Label for location of third-party plugins folder self.plugdir_label = nb.Label(plugins_frame, text=tr.tl('Plugins folder') + ':') self.plugdir_label.grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=next(row)) self.plugdir_entry = ttk.Entry(plugins_frame, takefocus=False, textvariable=self.plugdir) # Link StringVar to Entry widget self.plugdir_entry.grid(columnspan=4, padx=self.PADX, pady=self.BOXY, sticky=tk.EW, row=next(row)) cur_row = next(row) nb.Label( plugins_frame, # Help text in settings # LANG: Tip/label about how to disable plugins text=tr.tl( "Tip: You can disable a plugin by{CR}adding '{EXT}' to its folder name").format(EXT='.disabled') ).grid(columnspan=1, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row) # Open Plugin Folder Button self.open_plug_folder_btn = ttk.Button( plugins_frame, # LANG: Label on button used to open the Plugin Folder text=tr.tl('Open Plugins Folder'), command=lambda: open_folder(config.plugin_dir_path) ) self.open_plug_folder_btn.grid(column=1, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row) # Browse Button text = tr.tl('Browse...') # LANG: NOT-macOS Settings - files location selection button self.plugbutton = ttk.Button( plugins_frame, text=text, # LANG: Selecting the Location of the Plugin Directory on the Filesystem command=lambda: self.filebrowse(tr.tl('Plugin Directory Location'), self.plugdir) ) self.plugbutton.grid(column=2, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row) if config.default_journal_dir_path: # Appearance theme and language setting ttk.Button( plugins_frame, # LANG: Settings > Configuration - Label on 'reset journal files location to default' button text=tr.tl('Default'), command=self.plugdir_reset, state=tk.NORMAL if config.get_str('plugin_dir') else tk.DISABLED ).grid(column=3, padx=self.PADX, pady=self.PADY, sticky=tk.EW, row=cur_row) enabled_plugins = list(filter(lambda x: x.folder and x.module, plug.PLUGINS)) if enabled_plugins: ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) nb.Label( plugins_frame, # LANG: Label on list of enabled plugins text=tr.tl('Enabled Plugins')+':' # List of plugins in settings ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=next(row)) for plugin in enabled_plugins: curr_row = next(row) plugin_name = plugin.name if plugin.name == plugin.folder else f'{plugin.folder} ({plugin.name})' plugver = plugin.get_version() if plugver: plugin_name += f" v. {plugver}" label = nb.Label(plugins_frame, text=plugin_name) label.grid(column=0, columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=curr_row) btn = ttk.Button(plugins_frame, text=tr.tl("Disable")) # LANG: Disable an Enabled Plugin btn.configure(command=partial(self.disable_plugin, plugin, btn)) btn.grid(column=3, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=curr_row) ############################################################ # Show which plugins don't have Python 3.x support ############################################################ if plug.PLUGINS_not_py3: ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( columnspan=3, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) # LANG: Plugins - Label for list of 'enabled' plugins that don't work with Python 3.x nb.Label(plugins_frame, text=tr.tl('Plugins Without Python 3.x Support')+':').grid( padx=self.PADX, pady=self.PADY, sticky=tk.W, row=next(row) ) HyperlinkLabel( # LANG: Plugins - Label on URL to documentation about migrating plugins from Python 2.7 plugins_frame, text=tr.tl('Information on migrating plugins'), background=nb.Label().cget('background'), url='https://github.com/EDCD/EDMarketConnector/blob/main/PLUGINS.md#migration-from-python-27', underline=True ).grid(columnspan=2, padx=self.PADX, pady=self.PADY, sticky=tk.W, row=next(row)) for plugin in plug.PLUGINS_not_py3: if plugin.folder: # 'system' ones have this set to None to suppress listing in Plugins prefs tab nb.Label(plugins_frame, text=plugin.name).grid( columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=next(row) ) ############################################################ # Show disabled plugins ############################################################ disabled_plugins = list(filter(lambda x: x.folder and not x.module, plug.PLUGINS)) if disabled_plugins: ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( columnspan=3, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) nb.Label( plugins_frame, # LANG: Label on list of user-disabled plugins text=tr.tl('Disabled Plugins')+':' # List of plugins in settings ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=next(row)) for plugin in disabled_plugins: curr_row = next(row) nb.Label(plugins_frame, text=plugin.name).grid( column=0, columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=curr_row ) btn = ttk.Button( plugins_frame, text=tr.tl("Enable"), # LANG: Enable a Disabled Plugin ) btn.configure(command=partial(self.enable_plugin, plugin, btn)) btn.grid(column=3, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=curr_row) ############################################################ # Show plugins that failed to load ############################################################ if plug.PLUGINS_broken: ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid( columnspan=3, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=next(row) ) # LANG: Plugins - Label for list of 'broken' plugins that failed to load nb.Label(plugins_frame, text=tr.tl('Broken Plugins')+':').grid( padx=self.PADX, pady=self.PADY, sticky=tk.W, row=next(row) ) for plugin in plug.PLUGINS_broken: if plugin.folder: # 'system' ones have this set to None to suppress listing in Plugins prefs tab nb.Label(plugins_frame, text=plugin.name).grid( columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=next(row) ) # LANG: Label on Settings > Plugins tab notebook.add(plugins_frame, text=tr.tl('Plugins')) # Tab heading in settings def disable_plugin(self, plugin, button_widget=None): """Disable an existing plugin and restart.""" if button_widget: button_widget['state'] = tk.DISABLED self.update_idletasks() logger.debug(f"Calling plugin_stop() for {plugin.name}") if plugin._get_func('plugin_stop'): # Try to stop cleanly. We're going regardless... try: plugin.module.plugin_stop() except Exception as e: logger.error(f"Plugin {plugin.name} failed to stop cleanly: {e}") restart_msg = tr.tl( # LANG: Disabling a Plugin r"Disabling plugin {PLUGIN}. This will cause a restart. Click OK to continue..." ).format(PLUGIN=plugin.name) restart_box = tk.messagebox.Message( title=tr.tl('Restart Required'), # LANG: Title of Notification Popup for EDMC Restart message=restart_msg, type=tk.messagebox.OK ) restart_box.show() time.sleep(2) # Give plugin stop time to work. try: folder = Path(config.plugin_dir_path / plugin.folder) new_name = f"{folder}.disabled" if not folder.exists() and Path(new_name).exists(): logger.warning(f"Plugin {plugin.name} appears to already be disabled on disk.") else: folder.rename(new_name) logger.info(f"Disabled {plugin.name}.") if plugin in plug.PLUGINS: plug.PLUGINS.remove(plugin) # Remove PLugin from active list except FileExistsError: logger.warning(f"Unable to disable the plugin! Target directory {new_name} already exists.") return except PermissionError: logger.error(f"Permission denied when renaming {plugin.folder}. A file lock may be active.") return except Exception as e: logger.error(f"Unexpected error while disabling {plugin.name}: {e}") return self.req_restart = True self.apply() def enable_plugin(self, plugin, button_widget=None): """Enable an existing plugin and restart.""" if button_widget: button_widget['state'] = tk.DISABLED self.update_idletasks() logger.debug(f"Attempting to enable {plugin.name}") localfolder = plugin.folder + ".disabled" folder = Path(config.plugin_dir_path) / localfolder if not folder.exists(): logger.warning(f"Folder does not exist: {folder}") return new_folder = folder.with_name(plugin.folder.replace(".disabled", "")) if new_folder.exists(): logger.warning(f"Target folder already exists: {new_folder}") return try: folder.rename(new_folder) logger.info(f"Enabled {plugin.name}.") except FileNotFoundError: logger.warning(f"Unable to enable the plugin! Source directory {folder} was not found.") return except PermissionError: logger.error(f"Permission denied when enabling {plugin.name}. A file lock may be active.") return except Exception as e: logger.error(f"Failed to enable plugin {plugin.name}: {e}") return self.req_restart = True self.apply() def cmdrchanged(self, event=None): """ Notify plugins of cmdr change. :param event: Unused, defaults to None """ if self.cmdr != monitor.cmdr or self.is_beta != monitor.is_beta: # Cmdr has changed - update settings if self.cmdr is not False: # Don't notify on first run plug.notify_prefs_cmdr_changed(monitor.cmdr, monitor.is_beta) self.cmdr = monitor.cmdr self.is_beta = monitor.is_beta # Poll self.cmdrchanged_alarm = self.after(1000, self.cmdrchanged) def tabchanged(self, event: tk.Event) -> None: """Handle preferences active tab changing.""" self.outvarchanged() def outvarchanged(self, event: tk.Event | None = None) -> None: """Handle Output tab variable changes.""" self.out_label['state'] = tk.NORMAL self.out_csv_button['state'] = tk.NORMAL self.out_td_button['state'] = tk.NORMAL self.out_ship_button['state'] = tk.NORMAL def filebrowse(self, title, pathvar): """ Open a directory selection dialog. :param title: Title of the window :param pathvar: the path to start the dialog on """ import tkinter.filedialog directory = tkinter.filedialog.askdirectory( parent=self, initialdir=Path(pathvar.get()).expanduser(), title=title, mustexist=tk.TRUE ) if directory: pathvar.set(directory) self.outvarchanged() def displaypath(self, pathvar: tk.StringVar, entryfield: tk.Entry) -> None: """ Display a path in a locked tk.Entry. :param pathvar: the path to display :param entryfield: the entry in which to display the path """ # TODO: This is awful. entryfield['state'] = tk.NORMAL # must be writable to update entryfield.delete(0, tk.END) if sys.platform == 'win32': localized_path = get_localized_path(pathvar.get(), config.home) entryfield.insert(0, localized_path) # None if path doesn't exist else: if pathvar.get().startswith(config.home): entryfield.insert(0, '~' + pathvar.get()[len(config.home):]) else: entryfield.insert(0, pathvar.get()) entryfield['state'] = 'readonly' def logdir_reset(self) -> None: """Reset the log dir to the default.""" if config.default_journal_dir_path: self.logdir.set(config.default_journal_dir) self.outvarchanged() def plugdir_reset(self) -> None: """Reset the log dir to the default.""" if config.default_plugin_dir_path: self.plugdir.set(config.default_plugin_dir) self.outvarchanged() def disable_autoappupdatecheckingame_changed(self) -> None: """Save out the auto update check in game config.""" config.set('disable_autoappupdatecheckingame', self.disable_autoappupdatecheckingame.get()) # If it's now False, re-enable WinSparkle ? Need access to the AppWindow.updater variable to call down def alt_shipyard_open_changed(self) -> None: """Save out the status of the alt shipyard config.""" config.set('use_alt_shipyard_open', self.alt_shipyard_open.get()) def themecolorbrowse(self, index: int) -> None: """ Show a color browser. :param index: Index of the color type, 0 for dark text, 1 for dark highlight """ (_, color) = tkColorChooser.askcolor( self.theme_colors[index], title=self.theme_prompts[index], parent=self.parent ) if color: self.theme_colors[index] = color self.themevarchanged() def themevarchanged(self) -> None: """Update theme examples.""" self.theme_button_0['foreground'], self.theme_button_1['foreground'] = self.theme_colors if self.theme.get() == theme.THEME_DEFAULT: state = tk.DISABLED # type: ignore else: state = tk.NORMAL # type: ignore self.theme_label_0['state'] = state self.theme_label_1['state'] = state self.theme_button_0['state'] = state self.theme_button_1['state'] = state def hotkeystart(self, event: tk.Event[Any]) -> None: """Start listening for hotkeys.""" event.widget.bind('', self.hotkeylisten) event.widget.bind('', 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('') event.widget.unbind('') hotkeymgr.acquire_stop() # in case focus was lost while in the middle of acquiring event.widget.delete(0, tk.END) self.hotkey_text.insert( 0, # LANG: No hotkey/shortcut set hotkeymgr.display(self.hotkey_code, self.hotkey_mods) if self.hotkey_code else tr.tl('None')) def hotkeylisten(self, event: tk.Event[Any]) -> str: """ Hotkey handler. :param event: tkinter event for the hotkey :return: "break" as a literal, to halt processing """ good = hotkeymgr.fromevent(event) if good and isinstance(good, tuple): hotkey_code, hotkey_mods = good event.widget.delete(0, tk.END) event.widget.insert(0, hotkeymgr.display(hotkey_code, hotkey_mods)) if hotkey_code: # done (self.hotkey_code, self.hotkey_mods) = (hotkey_code, hotkey_mods) self.hotkey_only_btn['state'] = tk.NORMAL self.hotkey_play_btn['state'] = tk.NORMAL self.hotkey_only_btn.focus() # move to next widget - calls hotkeyend() implicitly else: if good is None: # clear (self.hotkey_code, self.hotkey_mods) = (0, 0) event.widget.delete(0, tk.END) if self.hotkey_code: event.widget.insert(0, hotkeymgr.display(self.hotkey_code, self.hotkey_mods)) self.hotkey_only_btn['state'] = tk.NORMAL self.hotkey_play_btn['state'] = tk.NORMAL else: # LANG: No hotkey/shortcut set event.widget.insert(0, tr.tl('None')) self.hotkey_only_btn['state'] = tk.DISABLED self.hotkey_play_btn['state'] = tk.DISABLED self.hotkey_only_btn.focus() # move to next widget - calls hotkeyend() implicitly return 'break' # stops further processing - insertion, Tab traversal etc def apply(self) -> None: # noqa: CCR001 """Update the config with the options set on the dialog.""" config.set('PrefsVersion', prefsVersion.stringToSerial(appversion_nobuild())) config.set( 'output', (self.out_td.get() and config.OUT_MKT_TD) + (self.out_csv.get() and config.OUT_MKT_CSV) + (config.OUT_MKT_MANUAL if not self.out_auto.get() else 0) + (self.out_ship.get() and config.OUT_SHIP) + (config.get_int('output') & ( config.OUT_EDDN_SEND_STATION_DATA | config.OUT_EDDN_SEND_NON_STATION | config.OUT_EDDN_DELAY )) ) # Market export format selected by user: one of CSV, TAB, PIPE, SEMICOLON _val = (self.mkt_export_type.get() or '').upper() if _val not in commodity.mkt_out_types: _val = 'SEMICOLON' config.set('mkt_export_type', _val) out_path = Path(self.outdir.get()) config.set('outdir', str(out_path.expanduser())) logdir = self.logdir.get() if config.default_journal_dir_path and logdir.lower() == config.default_journal_dir.lower(): config.set('journaldir', '') # default location else: config.set('journaldir', logdir) config.set('capi_fleetcarrier', self.capi_fleetcarrier.get()) if sys.platform == 'win32': config.set('hotkey_code', self.hotkey_code) config.set('hotkey_mods', self.hotkey_mods) config.set('hotkey_always', int(not self.hotkey_only.get())) config.set('hotkey_mute', int(not self.hotkey_play.get())) config.set('beta_optin', 0 if self.update_paths.get() == "Stable" else 1) config.set('shipyard_provider', self.shipyard_provider.get()) config.set('system_provider', self.system_provider.get()) config.set('station_provider', self.station_provider.get()) config.set('loglevel', self.select_loglevel.get()) edmclogger.set_console_loglevel(self.select_loglevel.get()) lang_codes = {v: k for k, v in self.languages.items()} # Codes by name config.set('language', lang_codes.get(self.lang.get()) or '') # or '' used here due to Default being None above tr.install(config.get_str('language', default=None)) # type: ignore # This sets self in weird ways. # Privacy options config.set('hide_private_group', self.hide_private_group.get()) config.set('hide_multicrew_captain', self.hide_multicrew_captain.get()) config.set('ui_scale', self.ui_scale.get()) config.set('ui_transparency', self.transparency.get()) config.set('always_ontop', self.always_ontop.get()) config.set('minimize_system_tray', self.minimize_system_tray.get()) if self.disable_system_tray.get() != config.get_bool('no_systray'): self.req_restart = True config.set('no_systray', self.disable_system_tray.get()) config.set('theme', self.theme.get()) config.set('dark_text', self.theme_colors[0]) config.set('dark_highlight', self.theme_colors[1]) theme.apply(self.parent) if self.plugdir.get() != config.get_str('plugin_dir'): plug_path = Path(self.plugdir.get()) config.set('plugin_dir', str(plug_path.expanduser())) self.req_restart = True # Notify if self.callback: self.callback() plug.notify_prefs_changed(monitor.cmdr, monitor.is_beta) self._destroy() # Send to the Post Config if we updated the update branch or need to restart post_flags = { 'Update': self.curr_update_track != self.update_paths.get(), # Just needs bool not true if else false 'Track': self.update_paths.get(), 'Parent': self, 'Restart_Req': self.req_restart # Simple Bool Needed } if self.callback: self.callback(**post_flags) def _destroy(self) -> None: """widget.destroy wrapper that does some extra cleanup.""" if self.cmdrchanged_alarm is not None: self.after_cancel(self.cmdrchanged_alarm) self.cmdrchanged_alarm = None self.parent.wm_attributes('-topmost', config.get_bool('always_ontop')) self.destroy() def attach_path_resolver(self, string_var: tk.StringVar) -> None: """ Establish Path resolution for Tkinter StringVars. ___ Args: string_var (tk.StringVar): The Tkinter string variable to monitor and resolve. """ trace_id = [""] def on_path_change(*args: Any) -> None: """Handle the internal trace callback.""" if trace_id[0]: string_var.trace_remove("write", trace_id[0]) current_val: str = string_var.get() resolved_val: str = self.resolve_path_str(current_val) if current_val != resolved_val: string_var.set(resolved_val) trace_id[0] = string_var.trace_add("write", on_path_change) trace_id[0] = string_var.trace_add("write", on_path_change) @staticmethod def resolve_path_str(path_str: str) -> str: """Expand ENV vars and Linux shortcuts (~) to abs paths.""" if not path_str: return '' try: if '%' in path_str or '$' in path_str: for key, value in os.environ.items(): path_str = path_str.replace(f'%{key}%', value).replace(f'${key}', value) return str(Path(path_str).expanduser().resolve()) except Exception: return path_str