Files
EDMarketConnector/prefs.py

1455 lines
62 KiB
Python

"""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("<Return>", lambda event: self.apply())
self.protocol("WM_DELETE_WINDOW", self._destroy)
# FIXME: Why are these being called when *creating* the Settings window?
# Selectively disable buttons depending on output settings
self.cmdrchanged()
self.themevarchanged()
# 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('<FocusIn>', self.hotkeystart)
self.hotkey_text.bind('<FocusOut>', self.hotkeyend)
self.hotkey_text.grid(column=1, columnspan=2, pady=self.BOXY, sticky=tk.W, row=cur_row)
# Hotkey/Shortcut setting
self.hotkey_only_btn = nb.Checkbutton(
config_frame,
# LANG: Configuration - Act on hotkey only when ED is in foreground
text=tr.tl('Only when Elite: Dangerous is the active app'),
variable=self.hotkey_only,
state=tk.NORMAL if self.hotkey_code else tk.DISABLED
)
self.hotkey_only_btn.grid(columnspan=4, padx=self.BUTTONX, pady=self.PADY, sticky=tk.W, row=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('<KeyPress>', self.hotkeylisten)
event.widget.bind('<KeyRelease>', self.hotkeylisten)
event.widget.delete(0, tk.END)
hotkeymgr.acquire_start()
def hotkeyend(self, event: tk.Event[Any]) -> None:
"""Stop listening for hotkeys."""
event.widget.unbind('<KeyPress>')
event.widget.unbind('<KeyRelease>')
hotkeymgr.acquire_stop() # in case focus was lost while in the middle of acquiring
event.widget.delete(0, tk.END)
self.hotkey_text.insert(
0,
# LANG: No hotkey/shortcut set
hotkeymgr.display(self.hotkey_code, self.hotkey_mods) if self.hotkey_code else tr.tl('None'))
def hotkeylisten(self, event: tk.Event[Any]) -> str:
"""
Hotkey handler.
:param event: tkinter event for the hotkey
:return: "break" as a literal, to halt processing
"""
good = hotkeymgr.fromevent(event)
if good and isinstance(good, tuple):
hotkey_code, hotkey_mods = good
event.widget.delete(0, tk.END)
event.widget.insert(0, hotkeymgr.display(hotkey_code, hotkey_mods))
if hotkey_code:
# done
(self.hotkey_code, self.hotkey_mods) = (hotkey_code, hotkey_mods)
self.hotkey_only_btn['state'] = tk.NORMAL
self.hotkey_play_btn['state'] = tk.NORMAL
self.hotkey_only_btn.focus() # move to next widget - calls hotkeyend() implicitly
else:
if good is None: # clear
(self.hotkey_code, self.hotkey_mods) = (0, 0)
event.widget.delete(0, tk.END)
if self.hotkey_code:
event.widget.insert(0, hotkeymgr.display(self.hotkey_code, self.hotkey_mods))
self.hotkey_only_btn['state'] = tk.NORMAL
self.hotkey_play_btn['state'] = tk.NORMAL
else:
# LANG: No hotkey/shortcut set
event.widget.insert(0, tr.tl('None'))
self.hotkey_only_btn['state'] = tk.DISABLED
self.hotkey_play_btn['state'] = tk.DISABLED
self.hotkey_only_btn.focus() # move to next widget - calls hotkeyend() implicitly
return 'break' # stops further processing - insertion, Tab traversal etc
def apply(self) -> None: # noqa: CCR001
"""Update the config with the options set on the dialog."""
config.set('PrefsVersion', prefsVersion.stringToSerial(appversion_nobuild()))
config.set(
'output',
(self.out_td.get() and config.OUT_MKT_TD) +
(self.out_csv.get() and config.OUT_MKT_CSV) +
(config.OUT_MKT_MANUAL if not self.out_auto.get() else 0) +
(self.out_ship.get() and config.OUT_SHIP) +
(config.get_int('output') & (
config.OUT_EDDN_SEND_STATION_DATA | config.OUT_EDDN_SEND_NON_STATION | config.OUT_EDDN_DELAY
))
)
# 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