1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-04-19 02:17:38 +03:00

Merge pull request #1411 from A-UNDERSCORE-D/sys-plaform-mypy

Ensure that platform guards are recognised by mypy
This commit is contained in:
Athanasius 2022-01-27 12:58:26 +00:00 committed by GitHub
commit 5e612d56b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1491 additions and 1383 deletions

View File

@ -2,3 +2,4 @@
follow_imports = skip
ignore_missing_imports = True
scripts_are_modules = True
; platform = darwin

View File

@ -228,6 +228,7 @@ handy if you want to step through the testing code to be sure of anything.
Otherwise, see the [pytest documentation](https://docs.pytest.org/en/stable/contents.html).
---
## Debugging network sends
Rather than risk sending bad data to a remote service, even if only through
@ -484,6 +485,20 @@ Please be verbose here, more info about weird choices is always prefered over ma
Additionally, if your hack is over around 5 lines, please include a `# HACK END` or similar comment to indicate the end of the hack.
# Use `sys.platform` for platform guards
`mypy` (and `pylance`) understand platform guards and will show unreachable code / resolve imports correctly
for platform specific things. However, this only works if you directly reference `sys.platform`, importantly
the following does not work:
```py
from sys import platform
if platform == 'darwin':
...
```
It **MUST** be `if sys.platform`.
---
## Build process

View File

@ -1,6 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Entry point for the main GUI application."""
from __future__ import annotations
import argparse
import html
@ -14,7 +15,6 @@ import webbrowser
from builtins import object, str
from os import chdir, environ
from os.path import dirname, join
from sys import platform
from time import localtime, strftime, time
from typing import TYPE_CHECKING, Optional, Tuple, Union
@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, Optional, Tuple, Union
# place for things like config.py reading .gitversion
if getattr(sys, 'frozen', False):
# Under py2exe sys.path[0] is the executable name
if platform == 'win32':
if sys.platform == 'win32':
chdir(dirname(sys.path[0]))
# Allow executable to be invoked from any cwd
environ['TCL_LIBRARY'] = join(dirname(sys.path[0]), 'lib', 'tcl')
@ -234,7 +234,7 @@ if __name__ == '__main__': # noqa: C901
"""Handle any edmc:// auth callback, else foreground existing window."""
logger.trace_if('frontier-auth.windows', 'Begin...')
if platform == 'win32':
if sys.platform == 'win32':
# If *this* instance hasn't locked, then another already has and we
# now need to do the edmc:// checks for auth callback
@ -370,8 +370,9 @@ if __name__ == '__main__': # noqa: C901
# isort: off
if TYPE_CHECKING:
from logging import TRACE # type: ignore # noqa: F401 # Needed to update mypy
import update
from infi.systray import SysTrayIcon
if sys.platform == 'win32':
from infi.systray import SysTrayIcon
# isort: on
def _(x: str) -> str:
@ -443,7 +444,7 @@ class AppWindow(object):
self.prefsdialog = None
if platform == 'win32':
if sys.platform == 'win32':
from infi.systray import SysTrayIcon
def open_window(systray: 'SysTrayIcon') -> None:
@ -456,8 +457,8 @@ class AppWindow(object):
plug.load_plugins(master)
if platform != 'darwin':
if platform == 'win32':
if sys.platform != 'darwin':
if sys.platform == 'win32':
self.w.wm_iconbitmap(default='EDMarketConnector.ico')
else:
@ -527,7 +528,7 @@ class AppWindow(object):
# LANG: Update button in main window
self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED)
self.theme_button = tk.Label(frame, width=32 if platform == 'darwin' else 28, state=tk.DISABLED)
self.theme_button = tk.Label(frame, width=32 if sys.platform == 'darwin' else 28, state=tk.DISABLED)
self.status = tk.Label(frame, name='status', anchor=tk.W)
ui_row = frame.grid_size()[1]
@ -540,14 +541,15 @@ class AppWindow(object):
theme.button_bind(self.theme_button, self.capi_request_data)
for child in frame.winfo_children():
child.grid_configure(padx=self.PADX, pady=(platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0)
child.grid_configure(padx=self.PADX, pady=(
sys.platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0)
# The type needs defining for adding the menu entry, but won't be
# properly set until later
self.updater: update.Updater = None
self.menubar = tk.Menu()
if platform == 'darwin':
if sys.platform == 'darwin':
# Can't handle (de)iconify if topmost is set, so suppress iconify button
# http://wiki.tcl.tk/13428 and p15 of
# https://developer.apple.com/legacy/library/documentation/Carbon/Conceptual/HandlingWindowsControls/windowscontrols.pdf
@ -603,7 +605,7 @@ class AppWindow(object):
self.help_menu.add_command(command=lambda: not self.HelpAbout.showing and self.HelpAbout(self.w))
self.menubar.add_cascade(menu=self.help_menu)
if platform == 'win32':
if sys.platform == 'win32':
# Must be added after at least one "real" menu entry
self.always_ontop = tk.BooleanVar(value=bool(config.get_int('always_ontop')))
self.system_menu = tk.Menu(self.menubar, name='system', tearoff=tk.FALSE)
@ -674,11 +676,11 @@ class AppWindow(object):
if config.get_str('geometry'):
match = re.match(r'\+([\-\d]+)\+([\-\d]+)', config.get_str('geometry'))
if match:
if platform == 'darwin':
if sys.platform == 'darwin':
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if int(match.group(2)) >= 0:
self.w.geometry(config.get_str('geometry'))
elif platform == 'win32':
elif sys.platform == 'win32':
# Check that the titlebar will be at least partly on screen
import ctypes
from ctypes.wintypes import POINT
@ -776,7 +778,7 @@ class AppWindow(object):
self.suit_shown = True
if not self.suit_shown:
if platform != 'win32':
if sys.platform != 'win32':
pady = 2
else:
@ -826,7 +828,7 @@ class AppWindow(object):
self.system_label['text'] = _('System') + ':' # LANG: Label for 'System' line in main UI
self.station_label['text'] = _('Station') + ':' # LANG: Label for 'Station' line in main UI
self.button['text'] = self.theme_button['text'] = _('Update') # LANG: Update button in main window
if platform == 'darwin':
if sys.platform == 'darwin':
self.menubar.entryconfigure(1, label=_('File')) # LANG: 'File' menu title on OSX
self.menubar.entryconfigure(2, label=_('Edit')) # LANG: 'Edit' menu title on OSX
self.menubar.entryconfigure(3, label=_('View')) # LANG: 'View' menu title on OSX
@ -873,7 +875,7 @@ class AppWindow(object):
self.button['state'] = self.theme_button['state'] = tk.DISABLED
if platform == 'darwin':
if sys.platform == 'darwin':
self.view_menu.entryconfigure(0, state=tk.DISABLED) # Status
self.file_menu.entryconfigure(0, state=tk.DISABLED) # Save Raw Data
@ -887,7 +889,7 @@ class AppWindow(object):
# LANG: Successfully authenticated with the Frontier website
self.status['text'] = _('Authentication successful')
if platform == 'darwin':
if sys.platform == 'darwin':
self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data
@ -1211,7 +1213,7 @@ class AppWindow(object):
companion.session.invalidate()
self.login()
except companion.ServerConnectionError as e:
except companion.ServerConnectionError as e: # TODO: unreachable (subclass of ServerLagging -- move to above)
logger.warning(f'Exception while contacting server: {e}')
err = self.status['text'] = str(e)
play_bad = True
@ -1429,7 +1431,7 @@ class AppWindow(object):
companion.session.auth_callback()
# LANG: Successfully authenticated with the Frontier website
self.status['text'] = _('Authentication successful')
if platform == 'darwin':
if sys.platform == 'darwin':
self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data
@ -1570,11 +1572,11 @@ class AppWindow(object):
# position over parent
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if platform != 'darwin' or parent.winfo_rooty() > 0:
if sys.platform != 'darwin' or parent.winfo_rooty() > 0:
self.geometry(f'+{parent.winfo_rootx():d}+{parent.winfo_rooty():d}')
# remove decoration
if platform == 'win32':
if sys.platform == 'win32':
self.attributes('-toolwindow', tk.TRUE)
self.resizable(tk.FALSE, tk.FALSE)
@ -1651,7 +1653,7 @@ class AppWindow(object):
"""
default_extension: str = ''
if platform == 'darwin':
if sys.platform == 'darwin':
default_extension = '.json'
timestamp: str = strftime('%Y-%m-%dT%H.%M.%S', localtime())
@ -1676,7 +1678,7 @@ class AppWindow(object):
def onexit(self, event=None) -> None:
"""Application shutdown procedure."""
if platform == 'win32':
if sys.platform == 'win32':
shutdown_thread = threading.Thread(target=self.systray.shutdown)
shutdown_thread.setDaemon(True)
shutdown_thread.start()
@ -1684,7 +1686,7 @@ class AppWindow(object):
config.set_shutdown() # Signal we're in shutdown now.
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if platform != 'darwin' or self.w.winfo_rooty() > 0:
if sys.platform != 'darwin' or self.w.winfo_rooty() > 0:
x, y = self.w.geometry().split('+')[1:3] # e.g. '212x170+2881+1267'
config.set('geometry', f'+{x}+{y}')

1115
config.py

File diff suppressed because it is too large Load Diff

473
config/__init__.py Normal file
View File

@ -0,0 +1,473 @@
"""
Code dealing with the configuration of the program.
Windows uses the Registry to store values in a flat manner.
Linux uses a file, but for commonality it's still a flat data structure.
macOS uses a 'defaults' object.
"""
__all__ = [
# defined in the order they appear in the file
'GITVERSION_FILE',
'appname',
'applongname',
'appcmdname',
'copyright',
'update_feed',
'update_interval',
'debug_senders',
'trace_on',
'capi_pretend_down',
'capi_debug_access_token',
'logger',
'git_shorthash_from_head',
'appversion',
'user_agent',
'appversion_nobuild',
'AbstractConfig',
'config'
]
import abc
import contextlib
import logging
import os
import pathlib
import re
import subprocess
import sys
import traceback
import warnings
from abc import abstractmethod
from typing import Any, Callable, List, Optional, Type, TypeVar, Union
import semantic_version
from constants import GITVERSION_FILE, applongname, appname
# Any of these may be imported by plugins
appcmdname = 'EDMC'
# appversion **MUST** follow Semantic Versioning rules:
# <https://semver.org/#semantic-versioning-specification-semver>
# Major.Minor.Patch(-prerelease)(+buildmetadata)
# NB: Do *not* import this, use the functions appversion() and appversion_nobuild()
_static_appversion = '5.3.0-beta4'
_cached_version: Optional[semantic_version.Version] = None
copyright = '© 2015-2019 Jonathan Harris, 2020-2022 EDCD'
update_feed = 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/releases/edmarketconnector.xml'
update_interval = 8*60*60
# Providers marked to be in debug mode. Generally this is expected to switch to sending data to a log file
debug_senders: List[str] = []
# TRACE logging code that should actually be used. Means not spamming it
# *all* if only interested in some things.
trace_on: List[str] = []
capi_pretend_down: bool = False
capi_debug_access_token: Optional[str] = None
# This must be done here in order to avoid an import cycle with EDMCLogging.
# Other code should use EDMCLogging.get_main_logger
if os.getenv("EDMC_NO_UI"):
logger = logging.getLogger(appcmdname)
else:
logger = logging.getLogger(appname)
_T = TypeVar('_T')
###########################################################################
def git_shorthash_from_head() -> str:
"""
Determine short hash for current git HEAD.
Includes `.DIRTY` if any changes have been made from HEAD
:return: str - None if we couldn't determine the short hash.
"""
shorthash: str = None # type: ignore
try:
git_cmd = subprocess.Popen('git rev-parse --short HEAD'.split(),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT
)
out, err = git_cmd.communicate()
except Exception as e:
logger.info(f"Couldn't run git command for short hash: {e!r}")
else:
shorthash = out.decode().rstrip('\n')
if re.match(r'^[0-9a-f]{7,}$', shorthash) is None:
logger.error(f"'{shorthash}' doesn't look like a valid git short hash, forcing to None")
shorthash = None # type: ignore
if shorthash is not None:
with contextlib.suppress(Exception):
result = subprocess.run('git diff --stat HEAD'.split(), capture_output=True)
if len(result.stdout) > 0:
shorthash += '.DIRTY'
if len(result.stderr) > 0:
logger.warning(f'Data from git on stderr:\n{str(result.stderr)}')
return shorthash
def appversion() -> semantic_version.Version:
"""
Determine app version including git short hash if possible.
:return: The augmented app version.
"""
global _cached_version
if _cached_version is not None:
return _cached_version
if getattr(sys, 'frozen', False):
# Running frozen, so we should have a .gitversion file
# Yes, .parent because if frozen we're inside library.zip
with open(pathlib.Path(sys.path[0]).parent / GITVERSION_FILE, 'r', encoding='utf-8') as gitv:
shorthash = gitv.read()
else:
# Running from source
shorthash = git_shorthash_from_head()
if shorthash is None:
shorthash = 'UNKNOWN'
_cached_version = semantic_version.Version(f'{_static_appversion}+{shorthash}')
return _cached_version
user_agent = f'EDCD-{appname}-{appversion()}'
def appversion_nobuild() -> semantic_version.Version:
"""
Determine app version without *any* build meta data.
This will not only strip any added git short hash, but also any trailing
'+<string>' in _static_appversion.
:return: App version without any build meta data.
"""
return appversion().truncate('prerelease')
###########################################################################
class AbstractConfig(abc.ABC):
"""Abstract root class of all platform specific Config implementations."""
OUT_MKT_EDDN = 1
# OUT_MKT_BPC = 2 # No longer supported
OUT_MKT_TD = 4
OUT_MKT_CSV = 8
OUT_SHIP = 16
# OUT_SHIP_EDS = 16 # Replaced by OUT_SHIP
# OUT_SYS_FILE = 32 # No longer supported
# OUT_STAT = 64 # No longer available
# OUT_SHIP_CORIOLIS = 128 # Replaced by OUT_SHIP
OUT_STATION_ANY = OUT_MKT_EDDN | OUT_MKT_TD | OUT_MKT_CSV
# OUT_SYS_EDSM = 256 # Now a plugin
# OUT_SYS_AUTO = 512 # Now always automatic
OUT_MKT_MANUAL = 1024
OUT_SYS_EDDN = 2048
OUT_SYS_DELAY = 4096
app_dir_path: pathlib.Path
plugin_dir_path: pathlib.Path
internal_plugin_dir_path: pathlib.Path
respath_path: pathlib.Path
home_path: pathlib.Path
default_journal_dir_path: pathlib.Path
identifier: str
__in_shutdown = False # Is the application currently shutting down ?
__auth_force_localserver = False # Should we use localhost for auth callback ?
__auth_force_edmc_protocol = False # Should we force edmc:// protocol ?
__eddn_url = None # Non-default EDDN URL
__eddn_tracking_ui = False # Show EDDN tracking UI ?
def __init__(self) -> None:
self.home_path = pathlib.Path.home()
def set_shutdown(self):
"""Set flag denoting we're in the shutdown sequence."""
self.__in_shutdown = True
@property
def shutting_down(self) -> bool:
"""
Determine if we're in the shutdown sequence.
:return: bool - True if in shutdown sequence.
"""
return self.__in_shutdown
def set_auth_force_localserver(self):
"""Set flag to force use of localhost web server for Frontier Auth callback."""
self.__auth_force_localserver = True
@property
def auth_force_localserver(self) -> bool:
"""
Determine if use of localhost is forced for Frontier Auth callback.
:return: bool - True if we should use localhost web server.
"""
return self.__auth_force_localserver
def set_auth_force_edmc_protocol(self):
"""Set flag to force use of localhost web server for Frontier Auth callback."""
self.__auth_force_edmc_protocol = True
@property
def auth_force_edmc_protocol(self) -> bool:
"""
Determine if use of localhost is forced for Frontier Auth callback.
:return: bool - True if we should use localhost web server.
"""
return self.__auth_force_edmc_protocol
def set_eddn_url(self, eddn_url: str):
"""Set the specified eddn URL."""
self.__eddn_url = eddn_url
@property
def eddn_url(self) -> Optional[str]:
"""
Provide the custom EDDN URL.
:return: str - Custom EDDN URL to use.
"""
return self.__eddn_url
def set_eddn_tracking_ui(self):
"""Activate EDDN tracking UI."""
self.__eddn_tracking_ui = True
@property
def eddn_tracking_ui(self) -> bool:
"""
Determine if the EDDN tracking UI be shown.
:return: bool - Should tracking UI be active?
"""
return self.__eddn_tracking_ui
@property
def app_dir(self) -> str:
"""Return a string version of app_dir."""
return str(self.app_dir_path)
@property
def plugin_dir(self) -> str:
"""Return a string version of plugin_dir."""
return str(self.plugin_dir_path)
@property
def internal_plugin_dir(self) -> str:
"""Return a string version of internal_plugin_dir."""
return str(self.internal_plugin_dir_path)
@property
def respath(self) -> str:
"""Return a string version of respath."""
return str(self.respath_path)
@property
def home(self) -> str:
"""Return a string version of home."""
return str(self.home_path)
@property
def default_journal_dir(self) -> str:
"""Return a string version of default_journal_dir."""
return str(self.default_journal_dir_path)
@staticmethod
def _suppress_call(
func: Callable[..., _T], exceptions: Union[Type[BaseException], List[Type[BaseException]]] = Exception,
*args: Any, **kwargs: Any
) -> Optional[_T]:
if exceptions is None:
exceptions = [Exception]
if not isinstance(exceptions, list):
exceptions = [exceptions]
with contextlib.suppress(*exceptions): # type: ignore # it works fine, mypy
return func(*args, **kwargs)
return None
def get(self, key: str, default: Union[list, str, bool, int] = None) -> Union[list, str, bool, int]:
"""
Return the data for the requested key, or a default.
:param key: The key data is being requested for.
:param default: The default to return if the key does not exist, defaults to None.
:raises OSError: On Windows, if a Registry error occurs.
:return: The data or the default.
"""
warnings.warn(DeprecationWarning('get is Deprecated. use the specific getter for your type'))
logger.debug('Attempt to use Deprecated get() method\n' + ''.join(traceback.format_stack()))
if (l := self._suppress_call(self.get_list, ValueError, key, default=None)) is not None:
return l
elif (s := self._suppress_call(self.get_str, ValueError, key, default=None)) is not None:
return s
elif (b := self._suppress_call(self.get_bool, ValueError, key, default=None)) is not None:
return b
elif (i := self._suppress_call(self.get_int, ValueError, key, default=None)) is not None:
return i
return default # type: ignore
@abstractmethod
def get_list(self, key: str, *, default: list = None) -> list:
"""
Return the list referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_list`.
"""
raise NotImplementedError
@abstractmethod
def get_str(self, key: str, *, default: str = None) -> str:
"""
Return the string referred to by the given key if it exists, or the default.
:param key: The key data is being requested for.
:param default: Default to return if the key does not exist, defaults to None.
:raises ValueError: If an internal error occurs getting or converting a value.
:raises OSError: On Windows, if a Registry error occurs.
:return: The requested data or the default.
"""
raise NotImplementedError
@abstractmethod
def get_bool(self, key: str, *, default: bool = None) -> bool:
"""
Return the bool referred to by the given key if it exists, or the default.
:param key: The key data is being requested for.
:param default: Default to return if the key does not exist, defaults to None
:raises ValueError: If an internal error occurs getting or converting a value
:raises OSError: On Windows, if a Registry error occurs.
:return: The requested data or the default
"""
raise NotImplementedError
def getint(self, key: str, *, default: int = 0) -> int:
"""
Getint is a Deprecated getter method.
See get_int for its replacement.
:raises OSError: On Windows, if a Registry error occurs.
"""
warnings.warn(DeprecationWarning('getint is Deprecated. Use get_int instead'))
logger.debug('Attempt to use Deprecated getint() method\n' + ''.join(traceback.format_stack()))
return self.get_int(key, default=default)
@abstractmethod
def get_int(self, key: str, *, default: int = 0) -> int:
"""
Return the int referred to by key if it exists in the config.
For legacy reasons, the default is 0 and not None.
:param key: The key data is being requested for.
:param default: Default to return if the key does not exist, defaults to 0.
:raises ValueError: If the internal representation of this key cannot be converted to an int.
:raises OSError: On Windows, if a Registry error occurs.
:return: The requested data or the default.
"""
raise NotImplementedError
@abstractmethod
def set(self, key: str, val: Union[int, str, List[str], bool]) -> None:
"""
Set the given key's data to the given value.
:param key: The key to set the value on.
:param val: The value to set the key's data to.
:raises ValueError: On an invalid type.
:raises OSError: On Windows, if a Registry error occurs.
"""
raise NotImplementedError
@abstractmethod
def delete(self, key: str, *, suppress=False) -> None:
"""
Delete the given key from the config.
:param key: The key to delete.
:param suppress: bool - Whether to suppress any errors. Useful in case
code to migrate settings is blindly removing an old key.
:raises OSError: On Windows, if a registry error occurs.
"""
raise NotImplementedError
@abstractmethod
def save(self) -> None:
"""
Save the current configuration.
:raises OSError: On Windows, if a Registry error occurs.
"""
raise NotImplementedError
@abstractmethod
def close(self) -> None:
"""Close this config and release any associated resources."""
raise NotImplementedError
def get_password(self, account: str) -> None:
"""Legacy password retrieval."""
warnings.warn("password subsystem is no longer supported", DeprecationWarning)
def set_password(self, account: str, password: str) -> None:
"""Legacy password setting."""
warnings.warn("password subsystem is no longer supported", DeprecationWarning)
def delete_password(self, account: str) -> None:
"""Legacy password deletion."""
warnings.warn("password subsystem is no longer supported", DeprecationWarning)
def get_config(*args, **kwargs) -> AbstractConfig:
"""
Get the appropriate config class for the current platform.
:param args: Args to be passed through to implementation.
:param kwargs: Args to be passed through to implementation.
:return: Instance of the implementation.
"""
if sys.platform == "darwin":
from .darwin import MacConfig
return MacConfig(*args, **kwargs)
elif sys.platform == "win32":
from .windows import WinConfig
return WinConfig(*args, **kwargs)
elif sys.platform == "linux":
from .linux import LinuxConfig
return LinuxConfig(*args, **kwargs)
else:
raise ValueError(f'Unknown platform: {sys.platform=}')
config = get_config()

184
config/darwin.py Normal file
View File

@ -0,0 +1,184 @@
import pathlib
import sys
from typing import Any, Dict, List, Union
from Foundation import ( # type: ignore
NSApplicationSupportDirectory, NSBundle, NSDocumentDirectory, NSSearchPathForDirectoriesInDomains, NSUserDefaults,
NSUserDomainMask
)
from config import AbstractConfig, appname, logger
assert sys.platform == 'darwin'
class MacConfig(AbstractConfig):
"""MacConfig is the implementation of AbstractConfig for Darwin based OSes."""
def __init__(self) -> None:
super().__init__()
support_path = pathlib.Path(
NSSearchPathForDirectoriesInDomains(
NSApplicationSupportDirectory, NSUserDomainMask, True
)[0]
)
self.app_dir_path = support_path / appname
self.app_dir_path.mkdir(exist_ok=True)
self.plugin_dir_path = self.app_dir_path / 'plugins'
self.plugin_dir_path.mkdir(exist_ok=True)
# Bundle IDs identify a singled app though out a system
if getattr(sys, 'frozen', False):
exe_dir = pathlib.Path(sys.executable).parent
self.internal_plugin_dir_path = exe_dir.parent / 'Library' / 'plugins'
self.respath_path = exe_dir.parent / 'Resources'
self.identifier = NSBundle.mainBundle().bundleIdentifier()
else:
file_dir = pathlib.Path(__file__).parent.parent
self.internal_plugin_dir_path = file_dir / 'plugins'
self.respath_path = file_dir
self.identifier = f'uk.org.marginal.{appname.lower()}'
NSBundle.mainBundle().infoDictionary()['CFBundleIdentifier'] = self.identifier
self.default_journal_dir_path = support_path / 'Frontier Developments' / 'Elite Dangerous'
self._defaults: Any = NSUserDefaults.standardUserDefaults()
self._settings: Dict[str, Union[int, str, list]] = dict(
self._defaults.persistentDomainForName_(self.identifier) or {}
) # make writeable
if (out_dir := self.get_str('out_dir')) is None or not pathlib.Path(out_dir).exists():
self.set('outdir', NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, True)[0])
def __raw_get(self, key: str) -> Union[None, list, str, int]:
"""
Retrieve the raw data for the given key.
:param str: str - The key data is being requested for.
:return: The requested data.
"""
res = self._settings.get(key)
# On MacOS Catalina, with python.org python 3.9.2 any 'list'
# has type __NSCFArray so a simple `isinstance(res, list)` is
# False. So, check it's not-None, and not the other types.
#
# If we can find where to import the definition of NSCFArray
# then we could possibly test against that.
if res is not None and not isinstance(res, str) and not isinstance(res, int):
return list(res)
return res
def get_str(self, key: str, *, default: str = None) -> str:
"""
Return the string referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_str`.
"""
res = self.__raw_get(key)
if res is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
if not isinstance(res, str):
raise ValueError(f'unexpected data returned from __raw_get: {type(res)=} {res}')
return res
def get_list(self, key: str, *, default: list = None) -> list:
"""
Return the list referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_list`.
"""
res = self.__raw_get(key)
if res is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
elif not isinstance(res, list):
raise ValueError(f'__raw_get returned unexpected type {type(res)=} {res!r}')
return res
def get_int(self, key: str, *, default: int = 0) -> int:
"""
Return the int referred to by key if it exists in the config.
Implements :meth:`AbstractConfig.get_int`.
"""
res = self.__raw_get(key)
if res is None:
return default
elif not isinstance(res, (str, int)):
raise ValueError(f'__raw_get returned unexpected type {type(res)=} {res!r}')
try:
return int(res)
except ValueError as e:
logger.error(f'__raw_get returned {res!r} which cannot be parsed to an int: {e}')
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
def get_bool(self, key: str, *, default: bool = None) -> bool:
"""
Return the bool referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_bool`.
"""
res = self.__raw_get(key)
if res is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
elif not isinstance(res, bool):
raise ValueError(f'__raw_get returned unexpected type {type(res)=} {res!r}')
return res
def set(self, key: str, val: Union[int, str, List[str], bool]) -> None:
"""
Set the given key's data to the given value.
Implements :meth:`AbstractConfig.set`.
"""
if self._settings is None:
raise ValueError('attempt to use a closed _settings')
if not isinstance(val, (bool, str, int, list)):
raise ValueError(f'Unexpected type for value {type(val)=}')
self._settings[key] = val
def delete(self, key: str, *, suppress=False) -> None:
"""
Delete the given key from the config.
Implements :meth:`AbstractConfig.delete`.
"""
try:
del self._settings[key]
except Exception:
if suppress:
pass
def save(self) -> None:
"""
Save the current configuration.
Implements :meth:`AbstractConfig.save`.
"""
self._defaults.setPersistentDomain_forName_(self._settings, self.identifier)
self._defaults.synchronize()
def close(self) -> None:
"""
Close this config and release any associated resources.
Implements :meth:`AbstractConfig.close`.
"""
self.save()
self._defaults = None

245
config/linux.py Normal file
View File

@ -0,0 +1,245 @@
"""Linux config implementation."""
import os
import pathlib
import sys
from configparser import ConfigParser
from typing import TYPE_CHECKING, List, Optional, Union
from config import AbstractConfig, appname, logger
assert sys.platform == 'linux'
class LinuxConfig(AbstractConfig):
"""Linux implementation of AbstractConfig."""
SECTION = 'config'
# TODO: I dislike this, would rather use a sane config file format. But here we are.
__unescape_lut = {'\\': '\\', 'n': '\n', ';': ';', 'r': '\r', '#': '#'}
__escape_lut = {'\\': '\\', '\n': 'n', ';': ';', '\r': 'r'}
def __init__(self, filename: Optional[str] = None) -> None:
super().__init__()
# http://standards.freedesktop.org/basedir-spec/latest/ar01s03.html
xdg_data_home = pathlib.Path(os.getenv('XDG_DATA_HOME', default='~/.local/share')).expanduser()
self.app_dir_path = xdg_data_home / appname
self.app_dir_path.mkdir(exist_ok=True, parents=True)
self.plugin_dir_path = self.app_dir_path / 'plugins'
self.plugin_dir_path.mkdir(exist_ok=True)
self.respath_path = pathlib.Path(__file__).parent.parent
self.internal_plugin_dir_path = self.respath_path / 'plugins'
self.default_journal_dir_path = None # type: ignore
self.identifier = f'uk.org.marginal.{appname.lower()}' # TODO: Unused?
config_home = pathlib.Path(os.getenv('XDG_CONFIG_HOME', default='~/.config')).expanduser()
self.filename = config_home / appname / f'{appname}.ini'
if filename is not None:
self.filename = pathlib.Path(filename)
self.filename.parent.mkdir(exist_ok=True, parents=True)
self.config: Optional[ConfigParser] = ConfigParser(comment_prefixes=('#',), interpolation=None)
self.config.read(self.filename) # read() ignores files that dont exist
# Ensure that our section exists. This is here because configparser will happily create files for us, but it
# does not magically create sections
try:
self.config[self.SECTION].get("this_does_not_exist", fallback=None)
except KeyError:
logger.info("Config section not found. Backing up existing file (if any) and readding a section header")
if self.filename.exists():
(self.filename.parent / f'{appname}.ini.backup').write_bytes(self.filename.read_bytes())
self.config.add_section(self.SECTION)
if (outdir := self.get_str('outdir')) is None or not pathlib.Path(outdir).is_dir():
self.set('outdir', self.home)
def __escape(self, s: str) -> str:
"""
Escape a string using self.__escape_lut.
This does NOT support multi-character escapes.
:param s: str - String to be escaped.
:return: str - The escaped string.
"""
out = ""
for c in s:
if c not in self.__escape_lut:
out += c
continue
out += '\\' + self.__escape_lut[c]
return out
def __unescape(self, s: str) -> str:
"""
Unescape a string.
:param s: str - The string to unescape.
:return: str - The unescaped string.
"""
out: List[str] = []
i = 0
while i < len(s):
c = s[i]
if c != '\\':
out.append(c)
i += 1
continue
# We have a backslash, check what its escaping
if i == len(s)-1:
raise ValueError('Escaped string has unescaped trailer')
unescaped = self.__unescape_lut.get(s[i+1])
if unescaped is None:
raise ValueError(f'Unknown escape: \\ {s[i+1]}')
out.append(unescaped)
i += 2
return "".join(out)
def __raw_get(self, key: str) -> Optional[str]:
"""
Get a raw data value from the config file.
:param key: str - The key data is being requested for.
:return: str - The raw data, if found.
"""
if self.config is None:
raise ValueError('Attempt to use a closed config')
return self.config[self.SECTION].get(key)
def get_str(self, key: str, *, default: str = None) -> str:
"""
Return the string referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_str`.
"""
data = self.__raw_get(key)
if data is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
if '\n' in data:
raise ValueError('asked for string, got list')
return self.__unescape(data)
def get_list(self, key: str, *, default: list = None) -> list:
"""
Return the list referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_list`.
"""
data = self.__raw_get(key)
if data is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
split = data.split('\n')
if split[-1] != ';':
raise ValueError('Encoded list does not have trailer sentinel')
return list(map(self.__unescape, split[:-1]))
def get_int(self, key: str, *, default: int = 0) -> int:
"""
Return the int referred to by key if it exists in the config.
Implements :meth:`AbstractConfig.get_int`.
"""
data = self.__raw_get(key)
if data is None:
return default
try:
return int(data)
except ValueError as e:
raise ValueError(f'requested {key=} as int cannot be converted to int') from e
def get_bool(self, key: str, *, default: bool = None) -> bool:
"""
Return the bool referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_bool`.
"""
if self.config is None:
raise ValueError('attempt to use a closed config')
data = self.__raw_get(key)
if data is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
return bool(int(data))
def set(self, key: str, val: Union[int, str, List[str]]) -> None:
"""
Set the given key's data to the given value.
Implements :meth:`AbstractConfig.set`.
"""
if self.config is None:
raise ValueError('attempt to use a closed config')
to_set: Optional[str] = None
if isinstance(val, bool):
to_set = str(int(val))
elif isinstance(val, str):
to_set = self.__escape(val)
elif isinstance(val, int):
to_set = str(val)
elif isinstance(val, list):
to_set = '\n'.join([self.__escape(s) for s in val] + [';'])
else:
raise ValueError(f'Unexpected type for value {type(val)=}')
self.config.set(self.SECTION, key, to_set)
self.save()
def delete(self, key: str, *, suppress=False) -> None:
"""
Delete the given key from the config.
Implements :meth:`AbstractConfig.delete`.
"""
if self.config is None:
raise ValueError('attempt to use a closed config')
self.config.remove_option(self.SECTION, key)
self.save()
def save(self) -> None:
"""
Save the current configuration.
Implements :meth:`AbstractConfig.save`.
"""
if self.config is None:
raise ValueError('attempt to use a closed config')
with open(self.filename, 'w', encoding='utf-8') as f:
self.config.write(f)
def close(self) -> None:
"""
Close this config and release any associated resources.
Implements :meth:`AbstractConfig.close`.
"""
self.save()
self.config = None

259
config/windows.py Normal file
View File

@ -0,0 +1,259 @@
"""Windows config implementation."""
# spell-checker: words folderid deps hkey edcd
import ctypes
import functools
import pathlib
import sys
import uuid
import winreg
from ctypes.wintypes import DWORD, HANDLE
from typing import List, Optional, Union
from config import AbstractConfig, applongname, appname, logger, update_interval
assert sys.platform == 'win32'
REG_RESERVED_ALWAYS_ZERO = 0
# This is the only way to do this from python without external deps (which do this anyway).
FOLDERID_Documents = uuid.UUID('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}')
FOLDERID_LocalAppData = uuid.UUID('{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}')
FOLDERID_Profile = uuid.UUID('{5E6C858F-0E22-4760-9AFE-EA3317B67173}')
FOLDERID_SavedGames = uuid.UUID('{4C5C32FF-BB9D-43b0-B5B4-2D72E54EAAA4}')
SHGetKnownFolderPath = ctypes.windll.shell32.SHGetKnownFolderPath
SHGetKnownFolderPath.argtypes = [ctypes.c_char_p, DWORD, HANDLE, ctypes.POINTER(ctypes.c_wchar_p)]
CoTaskMemFree = ctypes.windll.ole32.CoTaskMemFree
CoTaskMemFree.argtypes = [ctypes.c_void_p]
def known_folder_path(guid: uuid.UUID) -> Optional[str]:
"""Look up a Windows GUID to actual folder path name."""
buf = ctypes.c_wchar_p()
if SHGetKnownFolderPath(ctypes.create_string_buffer(guid.bytes_le), 0, 0, ctypes.byref(buf)):
return None
retval = buf.value # copy data
CoTaskMemFree(buf) # and free original
return retval
class WinConfig(AbstractConfig):
"""Implementation of AbstractConfig for Windows."""
def __init__(self, do_winsparkle=True) -> None:
self.app_dir_path = pathlib.Path(str(known_folder_path(FOLDERID_LocalAppData))) / appname
self.app_dir_path.mkdir(exist_ok=True)
self.plugin_dir_path = self.app_dir_path / 'plugins'
self.plugin_dir_path.mkdir(exist_ok=True)
if getattr(sys, 'frozen', False):
self.respath_path = pathlib.Path(sys.executable).parent
self.internal_plugin_dir_path = self.respath_path / 'plugins'
else:
self.respath_path = pathlib.Path(__file__).parent.parent
self.internal_plugin_dir_path = self.respath_path / 'plugins'
self.home_path = pathlib.Path.home()
journal_dir_str = known_folder_path(FOLDERID_SavedGames)
journaldir = pathlib.Path(journal_dir_str) if journal_dir_str is not None else None
self.default_journal_dir_path = None # type: ignore
if journaldir is not None:
self.default_journal_dir_path = journaldir / 'Frontier Developments' / 'Elite Dangerous'
create_key_defaults = functools.partial(
winreg.CreateKeyEx,
key=winreg.HKEY_CURRENT_USER,
access=winreg.KEY_ALL_ACCESS | winreg.KEY_WOW64_64KEY,
)
try:
self.__reg_handle: winreg.HKEYType = create_key_defaults(
sub_key=r'Software\Marginal\EDMarketConnector'
)
if do_winsparkle:
self.__setup_winsparkle()
except OSError:
logger.exception('could not create required registry keys')
raise
self.identifier = applongname
if (outdir_str := self.get_str('outdir')) is None or not pathlib.Path(outdir_str).is_dir():
docs = known_folder_path(FOLDERID_Documents)
self.set('outdir', docs if docs is not None else self.home)
def __setup_winsparkle(self):
"""Ensure the necessary Registry keys for WinSparkle are present."""
create_key_defaults = functools.partial(
winreg.CreateKeyEx,
key=winreg.HKEY_CURRENT_USER,
access=winreg.KEY_ALL_ACCESS | winreg.KEY_WOW64_64KEY,
)
try:
edcd_handle: winreg.HKEYType = create_key_defaults(sub_key=r'Software\EDCD\EDMarketConnector')
winsparkle_reg: winreg.HKEYType = winreg.CreateKeyEx(
edcd_handle, sub_key='WinSparkle', access=winreg.KEY_ALL_ACCESS | winreg.KEY_WOW64_64KEY
)
except OSError:
logger.exception('could not open WinSparkle handle')
raise
# set WinSparkle defaults - https://github.com/vslavik/winsparkle/wiki/Registry-Settings
winreg.SetValueEx(
winsparkle_reg, 'UpdateInterval', REG_RESERVED_ALWAYS_ZERO, winreg.REG_SZ, str(update_interval)
)
try:
winreg.QueryValueEx(winsparkle_reg, 'CheckForUpdates')
except FileNotFoundError:
# Key doesn't exist, set it to a default
winreg.SetValueEx(winsparkle_reg, 'CheckForUpdates', REG_RESERVED_ALWAYS_ZERO, winreg.REG_SZ, '1')
winsparkle_reg.Close()
edcd_handle.Close()
def __get_regentry(self, key: str) -> Union[None, list, str, int]:
"""Access the Registry for the raw entry."""
try:
value, _type = winreg.QueryValueEx(self.__reg_handle, key)
except FileNotFoundError:
# Key doesn't exist
return None
# The type returned is actually as we'd expect for each of these. The casts are here for type checkers and
# For programmers who want to actually know what is going on
if _type == winreg.REG_SZ:
return str(value)
elif _type == winreg.REG_DWORD:
return int(value)
elif _type == winreg.REG_MULTI_SZ:
return list(value)
else:
logger.warning(f'registry key {key=} returned unknown type {_type=} {value=}')
return None
def get_str(self, key: str, *, default: str = None) -> str:
"""
Return the string referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_str`.
"""
res = self.__get_regentry(key)
if res is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
elif not isinstance(res, str):
raise ValueError(f'Data from registry is not a string: {type(res)=} {res=}')
return res
def get_list(self, key: str, *, default: list = None) -> list:
"""
Return the list referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_list`.
"""
res = self.__get_regentry(key)
if res is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
elif not isinstance(res, list):
raise ValueError(f'Data from registry is not a list: {type(res)=} {res}')
return res
def get_int(self, key: str, *, default: int = 0) -> int:
"""
Return the int referred to by key if it exists in the config.
Implements :meth:`AbstractConfig.get_int`.
"""
res = self.__get_regentry(key)
if res is None:
return default
if not isinstance(res, int):
raise ValueError(f'Data from registry is not an int: {type(res)=} {res}')
return res
def get_bool(self, key: str, *, default: bool = None) -> bool:
"""
Return the bool referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_bool`.
"""
res = self.get_int(key, default=default) # type: ignore
if res is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
return bool(res)
def set(self, key: str, val: Union[int, str, List[str], bool]) -> None:
"""
Set the given key's data to the given value.
Implements :meth:`AbstractConfig.set`.
"""
reg_type = None
if isinstance(val, str):
reg_type = winreg.REG_SZ
winreg.SetValueEx(self.__reg_handle, key, REG_RESERVED_ALWAYS_ZERO, winreg.REG_SZ, val)
elif isinstance(val, int): # The original code checked for numbers.Integral, I dont think that is needed.
reg_type = winreg.REG_DWORD
elif isinstance(val, list):
reg_type = winreg.REG_MULTI_SZ
elif isinstance(val, bool):
reg_type = winreg.REG_DWORD
val = int(val)
else:
raise ValueError(f'Unexpected type for value {type(val)=}')
# Its complaining about the list, it works, tested on windows, ignored.
winreg.SetValueEx(self.__reg_handle, key, REG_RESERVED_ALWAYS_ZERO, reg_type, val) # type: ignore
def delete(self, key: str, *, suppress=False) -> None:
"""
Delete the given key from the config.
'key' is relative to the base Registry path we use.
Implements :meth:`AbstractConfig.delete`.
"""
try:
winreg.DeleteValue(self.__reg_handle, key)
except OSError:
if suppress:
return
raise
def save(self) -> None:
"""
Save the configuration.
Not required for WinConfig as Registry keys are flushed on write.
"""
pass
def close(self):
"""
Close this config and release any associated resources.
Implements :meth:`AbstractConfig.close`.
"""
self.__reg_handle.Close()

View File

@ -2,11 +2,11 @@
import json
import pathlib
import sys
import time
import tkinter as tk
from calendar import timegm
from os.path import getsize, isdir, isfile
from sys import platform
from typing import Any, Dict
from config import config
@ -14,11 +14,11 @@ from EDMCLogging import get_main_logger
logger = get_main_logger()
if platform == 'darwin':
if sys.platform == 'darwin':
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
elif platform == 'win32':
elif sys.platform == 'win32':
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
@ -71,26 +71,25 @@ class Dashboard(FileSystemEventHandler):
# File system events are unreliable/non-existent over network drives on Linux.
# We can't easily tell whether a path points to a network drive, so assume
# any non-standard logdir might be on a network drive and poll instead.
polling = platform != 'win32'
if not polling and not self.observer:
if not (sys.platform != 'win32') and not self.observer:
logger.debug('Setting up observer...')
self.observer = Observer()
self.observer.daemon = True
self.observer.start()
logger.debug('Done')
elif polling and self.observer:
elif (sys.platform != 'win32') and self.observer:
logger.debug('Using polling, stopping observer...')
self.observer.stop()
self.observer = None # type: ignore
logger.debug('Done')
if not self.observed and not polling:
if not self.observed and not (sys.platform != 'win32'):
logger.debug('Starting observer...')
self.observed = self.observer.schedule(self, self.currentdir)
logger.debug('Done')
logger.info(f'{polling and "Polling" or "Monitoring"} Dashboard "{self.currentdir}"')
logger.info(f'{(sys.platform != "win32") and "Polling" or "Monitoring"} Dashboard "{self.currentdir}"')
# Even if we're not intending to poll, poll at least once to process pre-existing
# data and to check whether the watchdog thread has crashed due to events not

View File

@ -1,10 +1,10 @@
"""Implements locking of Journal directory."""
import pathlib
import sys
import tkinter as tk
from enum import Enum
from os import getpid as os_getpid
from sys import platform
from tkinter import ttk
from typing import TYPE_CHECKING, Callable, Optional
@ -94,7 +94,7 @@ class JournalLock:
:return: LockResult - See the class Enum definition
"""
if platform == 'win32':
if sys.platform == 'win32':
logger.trace_if('journal-lock', 'win32, using msvcrt')
# win32 doesn't have fcntl, so we have to use msvcrt
import msvcrt
@ -143,7 +143,7 @@ class JournalLock:
return True # We weren't locked, and still aren't
unlocked = False
if platform == 'win32':
if sys.platform == 'win32':
logger.trace_if('journal-lock', 'win32, using msvcrt')
# win32 doesn't have fcntl, so we have to use msvcrt
import msvcrt
@ -206,10 +206,10 @@ class JournalLock:
self.title(_('Journal directory already locked'))
# remove decoration
if platform == 'win32':
if sys.platform == 'win32':
self.attributes('-toolwindow', tk.TRUE)
elif platform == 'darwin':
elif sys.platform == 'darwin':
# http://wiki.tcl.tk/13428
parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility')

21
l10n.py
View File

@ -12,7 +12,6 @@ import warnings
from collections import OrderedDict
from contextlib import suppress
from os.path import basename, dirname, isdir, isfile, join
from sys import platform
from typing import TYPE_CHECKING, Dict, Iterable, Optional, Set, TextIO, Union, cast
if TYPE_CHECKING:
@ -37,12 +36,12 @@ LANGUAGE_ID = '!Language'
LOCALISATION_DIR = 'L10n'
if platform == 'darwin':
if sys.platform == 'darwin':
from Foundation import ( # type: ignore # exists on Darwin
NSLocale, NSNumberFormatter, NSNumberFormatterDecimalStyle
)
elif platform == 'win32':
elif sys.platform == 'win32':
import ctypes
from ctypes.wintypes import BOOL, DWORD, LPCVOID, LPCWSTR, LPWSTR
if TYPE_CHECKING:
@ -176,7 +175,7 @@ class _Translations:
def available(self) -> Set[str]:
"""Return a list of available language codes."""
path = self.respath()
if getattr(sys, 'frozen', False) and platform == 'darwin':
if getattr(sys, 'frozen', False) and sys.platform == 'darwin':
available = {
x[:-len('.lproj')] for x in os.listdir(path)
if x.endswith('.lproj') and isfile(join(x, 'Localizable.strings'))
@ -204,7 +203,7 @@ class _Translations:
def respath(self) -> pathlib.Path:
"""Path to localisation files."""
if getattr(sys, 'frozen', False):
if platform == 'darwin':
if sys.platform == 'darwin':
return (pathlib.Path(sys.executable).parents[0] / os.pardir / 'Resources').resolve()
return pathlib.Path(dirname(sys.executable)) / LOCALISATION_DIR
@ -233,7 +232,7 @@ class _Translations:
except OSError:
logger.exception(f'could not open {f}')
elif getattr(sys, 'frozen', False) and platform == 'darwin':
elif getattr(sys, 'frozen', False) and sys.platform == 'darwin':
return (self.respath() / f'{lang}.lproj' / 'Localizable.strings').open('r', encoding='utf-16')
return (self.respath() / f'{lang}.strings').open('r', encoding='utf-8')
@ -243,7 +242,7 @@ class _Locale:
"""Locale holds a few utility methods to convert data to and from localized versions."""
def __init__(self) -> None:
if platform == 'darwin':
if sys.platform == 'darwin':
self.int_formatter = NSNumberFormatter.alloc().init()
self.int_formatter.setNumberStyle_(NSNumberFormatterDecimalStyle)
self.float_formatter = NSNumberFormatter.alloc().init()
@ -276,7 +275,7 @@ class _Locale:
if decimals == 0 and not isinstance(number, numbers.Integral):
number = int(round(number))
if platform == 'darwin':
if sys.platform == 'darwin':
if not decimals and isinstance(number, numbers.Integral):
return self.int_formatter.stringFromNumber_(number)
@ -298,7 +297,7 @@ class _Locale:
:param string: The string to convert
:return: None if the string cannot be parsed, otherwise an int or float dependant on input data.
"""
if platform == 'darwin':
if sys.platform == 'darwin':
return self.float_formatter.numberFromString_(string)
with suppress(ValueError):
@ -321,10 +320,10 @@ class _Locale:
:return: The preferred language list
"""
languages: Iterable[str]
if platform == 'darwin':
if sys.platform == 'darwin':
languages = NSLocale.preferredLanguages()
elif platform != 'win32':
elif sys.platform != 'win32':
# POSIX
lang = locale.getlocale()[0]
languages = lang and [lang.replace('_', '-')] or []

View File

@ -1,15 +1,18 @@
"""Monitor for new Journal files and contents of latest."""
# v [sic]
# spell-checker: words onfoot unforseen relog fsdjump suitloadoutid slotid suitid loadoutid fauto Intimidator
# spell-checker: words joinacrew quitacrew sellshiponrebuy newbal navroute npccrewpaidwage sauto
import json
import pathlib
import queue
import re
import sys
import threading
from calendar import timegm
from collections import OrderedDict, defaultdict
from os import SEEK_END, SEEK_SET, listdir
from os.path import basename, expanduser, isdir, join
from sys import platform
from time import gmtime, localtime, mktime, sleep, strftime, strptime, time
from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, MutableMapping, Optional
from typing import OrderedDict as OrderedDictT
@ -33,7 +36,7 @@ if TYPE_CHECKING:
def _(x: str) -> str:
return x
if platform == 'darwin':
if sys.platform == 'darwin':
from fcntl import fcntl
from AppKit import NSWorkspace
@ -41,7 +44,7 @@ if platform == 'darwin':
from watchdog.observers import Observer
F_GLOBAL_NOCACHE = 55
elif platform == 'win32':
elif sys.platform == 'win32':
import ctypes
from ctypes.wintypes import BOOL, HWND, LPARAM, LPWSTR
@ -62,6 +65,10 @@ elif platform == 'win32':
else:
# Linux's inotify doesn't work over CIFS or NFS, so poll
FileSystemEventHandler = object # dummy
if TYPE_CHECKING:
# this isn't ever used, but this will make type checking happy
from watchdog.events import FileCreatedEvent
from watchdog.observers import Observer
# Journal handler
@ -225,7 +232,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
# File system events are unreliable/non-existent over network drives on Linux.
# We can't easily tell whether a path points to a network drive, so assume
# any non-standard logdir might be on a network drive and poll instead.
polling = bool(config.get_str('journaldir')) and platform != 'win32'
polling = bool(config.get_str('journaldir')) and sys.platform != 'win32'
if not polling and not self.observer:
logger.debug('Not polling, no observer, starting an observer...')
self.observer = Observer()
@ -282,6 +289,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
if self.observed:
logger.debug('self.observed: Calling unschedule_all()')
self.observed = None
assert self.observer is not None, 'Observer was none but it is in use?'
self.observer.unschedule_all()
logger.debug('Done')
@ -341,7 +349,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
logfile = self.logfile
if logfile:
loghandle: BinaryIO = open(logfile, 'rb', 0) # unbuffered
if platform == 'darwin':
if sys.platform == 'darwin':
fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB
self.catching_up = True
@ -394,9 +402,13 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
self.event_queue.put(None)
self.live = False
emitter = None
# Watchdog thread -- there is a way to get this by using self.observer.emitters and checking for an attribute:
# watch, but that may have unforseen differences in behaviour.
emitter = self.observed and self.observer._emitter_for_watch[self.observed] # Note: Uses undocumented attribute
if self.observed:
assert self.observer is not None, 'self.observer is None but also in use?'
# Note: Uses undocumented attribute
emitter = self.observed and self.observer._emitter_for_watch[self.observed]
logger.debug('Entering loop...')
while True:
@ -452,7 +464,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
if logfile:
loghandle = open(logfile, 'rb', 0) # unbuffered
if platform == 'darwin':
if sys.platform == 'darwin':
fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB
log_pos = 0
@ -699,7 +711,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
# This event is logged when a player (on foot) gets into a ship or SRV
# Parameters:
# • SRV: true if getting into SRV, false if getting into a ship
# • Taxi: true when boarding a taxi transposrt ship
# • Taxi: true when boarding a taxi transport ship
# • Multicrew: true when boarding another players vessel
# • ID: players ship ID (if players own vessel)
# • StarSystem
@ -727,7 +739,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
#
# Parameters:
# • SRV: true if getting out of SRV, false if getting out of a ship
# • Taxi: true when getting out of a taxi transposrt ship
# • Taxi: true when getting out of a taxi transport ship
# • Multicrew: true when getting out of another players vessel
# • ID: players ship ID (if players own vessel)
# • StarSystem
@ -1942,12 +1954,12 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
:return: bool - True if the game is running.
"""
if platform == 'darwin':
if sys.platform == 'darwin':
for app in NSWorkspace.sharedWorkspace().runningApplications():
if app.bundleIdentifier() == 'uk.co.frontier.EliteDangerous':
return True
elif platform == 'win32':
elif sys.platform == 'win32':
def WindowTitle(h): # noqa: N802 # type: ignore
if h:
length = GetWindowTextLength(h) + 1

View File

@ -4,22 +4,21 @@
# - OSX: page background should be a darker gray than systemWindowBody
# selected tab foreground should be White when the window is active
#
from sys import platform
import sys
import tkinter as tk
from tkinter import ttk
# Entire file may be imported by plugins
# Can't do this with styles on OSX - http://www.tkdocs.com/tutorial/styles.html#whydifficult
if platform == 'darwin':
if sys.platform == 'darwin':
from platform import mac_ver
PAGEFG = 'systemButtonText'
PAGEBG = 'systemButtonActiveDarkShadow'
elif platform == 'win32':
elif sys.platform == 'win32':
PAGEFG = 'SystemWindowText'
PAGEBG = 'SystemWindow' # typically white
PAGEBG = 'SystemWindow' # typically white
class Notebook(ttk.Notebook):
@ -29,14 +28,14 @@ class Notebook(ttk.Notebook):
ttk.Notebook.__init__(self, master, **kw)
style = ttk.Style()
if platform=='darwin':
if list(map(int, mac_ver()[0].split('.'))) >= [10,10]:
if sys.platform == 'darwin':
if list(map(int, mac_ver()[0].split('.'))) >= [10, 10]:
# Hack for tab appearance with 8.5 on Yosemite & El Capitan. For proper fix see
# https://github.com/tcltk/tk/commit/55c4dfca9353bbd69bbcec5d63bf1c8dfb461e25
style.configure('TNotebook.Tab', padding=(12,10,12,2))
style.configure('TNotebook.Tab', padding=(12, 10, 12, 2))
style.map('TNotebook.Tab', foreground=[('selected', '!background', 'systemWhite')])
self.grid(sticky=tk.NSEW) # Already padded apropriately
elif platform == 'win32':
self.grid(sticky=tk.NSEW) # Already padded apropriately
elif sys.platform == 'win32':
style.configure('nb.TFrame', background=PAGEBG)
style.configure('nb.TButton', background=PAGEBG)
style.configure('nb.TCheckbutton', foreground=PAGEFG, background=PAGEBG)
@ -47,56 +46,60 @@ class Notebook(ttk.Notebook):
self.grid(padx=10, pady=10, sticky=tk.NSEW)
class Frame(platform == 'darwin' and tk.Frame or ttk.Frame):
class Frame(sys.platform == 'darwin' and tk.Frame or ttk.Frame):
def __init__(self, master=None, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
kw['background'] = kw.pop('background', PAGEBG)
tk.Frame.__init__(self, master, **kw)
tk.Frame(self).grid(pady=5)
elif platform == 'win32':
elif sys.platform == 'win32':
ttk.Frame.__init__(self, master, style='nb.TFrame', **kw)
ttk.Frame(self).grid(pady=5) # top spacer
ttk.Frame(self).grid(pady=5) # top spacer
else:
ttk.Frame.__init__(self, master, **kw)
ttk.Frame(self).grid(pady=5) # top spacer
self.configure(takefocus = 1) # let the frame take focus so that no particular child is focused
ttk.Frame(self).grid(pady=5) # top spacer
self.configure(takefocus=1) # let the frame take focus so that no particular child is focused
class Label(tk.Label):
def __init__(self, master=None, **kw):
if platform in ['darwin', 'win32']:
if sys.platform in ['darwin', 'win32']:
kw['foreground'] = kw.pop('foreground', PAGEFG)
kw['background'] = kw.pop('background', PAGEBG)
else:
kw['foreground'] = kw.pop('foreground', ttk.Style().lookup('TLabel', 'foreground'))
kw['background'] = kw.pop('background', ttk.Style().lookup('TLabel', 'background'))
tk.Label.__init__(self, master, **kw) # Just use tk.Label on all platforms
tk.Label.__init__(self, master, **kw) # Just use tk.Label on all platforms
class Entry(platform == 'darwin' and tk.Entry or ttk.Entry):
class Entry(sys.platform == 'darwin' and tk.Entry or ttk.Entry):
def __init__(self, master=None, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
kw['highlightbackground'] = kw.pop('highlightbackground', PAGEBG)
tk.Entry.__init__(self, master, **kw)
else:
ttk.Entry.__init__(self, master, **kw)
class Button(platform == 'darwin' and tk.Button or ttk.Button):
class Button(sys.platform == 'darwin' and tk.Button or ttk.Button):
def __init__(self, master=None, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
kw['highlightbackground'] = kw.pop('highlightbackground', PAGEBG)
tk.Button.__init__(self, master, **kw)
elif platform == 'win32':
elif sys.platform == 'win32':
ttk.Button.__init__(self, master, style='nb.TButton', **kw)
else:
ttk.Button.__init__(self, master, **kw)
class ColoredButton(platform == 'darwin' and tk.Label or tk.Button):
class ColoredButton(sys.platform == 'darwin' and tk.Label or tk.Button):
def __init__(self, master=None, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
# Can't set Button background on OSX, so use a Label instead
kw['relief'] = kw.pop('relief', tk.RAISED)
self._command = kw.pop('command', None)
@ -105,52 +108,55 @@ class ColoredButton(platform == 'darwin' and tk.Label or tk.Button):
else:
tk.Button.__init__(self, master, **kw)
if platform == 'darwin':
if sys.platform == 'darwin':
def _press(self, event):
self._command()
class Checkbutton(platform == 'darwin' and tk.Checkbutton or ttk.Checkbutton):
class Checkbutton(sys.platform == 'darwin' and tk.Checkbutton or ttk.Checkbutton):
def __init__(self, master=None, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
kw['foreground'] = kw.pop('foreground', PAGEFG)
kw['background'] = kw.pop('background', PAGEBG)
tk.Checkbutton.__init__(self, master, **kw)
elif platform == 'win32':
elif sys.platform == 'win32':
ttk.Checkbutton.__init__(self, master, style='nb.TCheckbutton', **kw)
else:
ttk.Checkbutton.__init__(self, master, **kw)
class Radiobutton(platform == 'darwin' and tk.Radiobutton or ttk.Radiobutton):
class Radiobutton(sys.platform == 'darwin' and tk.Radiobutton or ttk.Radiobutton):
def __init__(self, master=None, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
kw['foreground'] = kw.pop('foreground', PAGEFG)
kw['background'] = kw.pop('background', PAGEBG)
tk.Radiobutton.__init__(self, master, **kw)
elif platform == 'win32':
elif sys.platform == 'win32':
ttk.Radiobutton.__init__(self, master, style='nb.TRadiobutton', **kw)
else:
ttk.Radiobutton.__init__(self, master, **kw)
class OptionMenu(platform == 'darwin' and tk.OptionMenu or ttk.OptionMenu):
class OptionMenu(sys.platform == 'darwin' and tk.OptionMenu or ttk.OptionMenu):
def __init__(self, master, variable, default=None, *values, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
variable.set(default)
bg = kw.pop('background', PAGEBG)
tk.OptionMenu.__init__(self, master, variable, *values, **kw)
self['background'] = bg
elif platform == 'win32':
elif sys.platform == 'win32':
# OptionMenu derives from Menubutton at the Python level, so uses Menubutton's style
ttk.OptionMenu.__init__(self, master, variable, default, *values, style='nb.TMenubutton', **kw)
self['menu'].configure(background = PAGEBG)
self['menu'].configure(background=PAGEBG)
# Workaround for https://bugs.python.org/issue25684
for i in range(0, self['menu'].index('end')+1):
self['menu'].entryconfig(i, variable=variable)
else:
ttk.OptionMenu.__init__(self, master, variable, default, *values, **kw)
self['menu'].configure(background = ttk.Style().lookup('TMenu', 'background'))
self['menu'].configure(background=ttk.Style().lookup('TMenu', 'background'))
# Workaround for https://bugs.python.org/issue25684
for i in range(0, self['menu'].index('end')+1):
self['menu'].entryconfig(i, variable=variable)

View File

@ -3,10 +3,10 @@
import contextlib
import logging
import sys
import tkinter as tk
import webbrowser
from os.path import expanduser, expandvars, join, normpath
from sys import platform
from tkinter import colorchooser as tkColorChooser # type: ignore # noqa: N812
from tkinter import ttk
from types import TracebackType
@ -154,7 +154,7 @@ class AutoInc(contextlib.AbstractContextManager):
return None
if platform == 'darwin':
if sys.platform == 'darwin':
import objc # type: ignore
from Foundation import NSFileManager # type: ignore
try:
@ -179,7 +179,7 @@ if platform == 'darwin':
was_accessible_at_launch = AXIsProcessTrusted() # type: ignore
elif platform == 'win32':
elif sys.platform == 'win32':
import ctypes
import winreg
from ctypes.wintypes import HINSTANCE, HWND, LPARAM, LPCWSTR, LPVOID, LPWSTR, MAX_PATH, POINT, RECT, SIZE, UINT
@ -246,7 +246,7 @@ class PreferencesDialog(tk.Toplevel):
self.parent = parent
self.callback = callback
if platform == 'darwin':
if sys.platform == 'darwin':
# LANG: File > Preferences menu entry for macOS
self.title(_('Preferences'))
@ -258,15 +258,15 @@ class PreferencesDialog(tk.Toplevel):
self.transient(parent)
# position over parent
if platform != 'darwin' or parent.winfo_rooty() > 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if sys.platform != 'darwin' or parent.winfo_rooty() > 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
# TODO this is fixed supposedly.
self.geometry(f'+{parent.winfo_rootx()}+{parent.winfo_rooty()}')
# remove decoration
if platform == 'win32':
if sys.platform == 'win32':
self.attributes('-toolwindow', tk.TRUE)
elif platform == 'darwin':
elif sys.platform == 'darwin':
# http://wiki.tcl.tk/13428
parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility')
@ -294,7 +294,7 @@ class PreferencesDialog(tk.Toplevel):
self.__setup_appearance_tab(notebook)
self.__setup_plugin_tab(notebook)
if platform == 'darwin':
if sys.platform == 'darwin':
self.protocol("WM_DELETE_WINDOW", self.apply) # close button applies changes
else:
@ -322,7 +322,7 @@ class PreferencesDialog(tk.Toplevel):
self.grab_set()
# Ensure fully on-screen
if platform == 'win32' and CalculatePopupWindowPosition:
if sys.platform == 'win32' and CalculatePopupWindowPosition:
position = RECT()
GetWindowRect(GetParent(self.winfo_id()), position)
if CalculatePopupWindowPosition(
@ -396,7 +396,7 @@ class PreferencesDialog(tk.Toplevel):
self.outdir_entry = nb.Entry(output_frame, takefocus=False)
self.outdir_entry.grid(columnspan=2, padx=self.PADX, pady=(0, self.PADY), sticky=tk.EW, row=row.get())
if platform == 'darwin':
if sys.platform == 'darwin':
text = (_('Change...')) # LANG: macOS Preferences - files location selection button
else:
@ -446,7 +446,7 @@ class PreferencesDialog(tk.Toplevel):
self.logdir_entry.grid(columnspan=4, padx=self.PADX, pady=(0, self.PADY), sticky=tk.EW, row=row.get())
if platform == 'darwin':
if sys.platform == 'darwin':
text = (_('Change...')) # LANG: macOS Preferences - files location selection button
else:
@ -470,7 +470,7 @@ class PreferencesDialog(tk.Toplevel):
state=tk.NORMAL if config.get_str('journaldir') else tk.DISABLED
).grid(column=2, pady=self.PADY, sticky=tk.EW, row=row.get())
if platform in ('darwin', 'win32'):
if sys.platform in ('darwin', 'win32'):
ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid(
columnspan=4, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW, row=row.get()
)
@ -482,11 +482,11 @@ class PreferencesDialog(tk.Toplevel):
nb.Label(
config_frame,
text=_('Keyboard shortcut') if # LANG: Hotkey/Shortcut settings prompt on OSX
platform == 'darwin' else
sys.platform == 'darwin' else
_('Hotkey') # LANG: Hotkey/Shortcut settings prompt on Windows
).grid(padx=self.PADX, sticky=tk.W, row=row.get())
if platform == 'darwin' and not was_accessible_at_launch:
if sys.platform == 'darwin' and not was_accessible_at_launch:
if AXIsProcessTrusted():
# Shortcut settings prompt on OSX
nb.Label(
@ -511,7 +511,8 @@ class PreferencesDialog(tk.Toplevel):
)
else:
self.hotkey_text = nb.Entry(config_frame, width=(20 if platform == 'darwin' else 30), justify=tk.CENTER)
self.hotkey_text = nb.Entry(config_frame, width=(
20 if sys.platform == 'darwin' else 30), justify=tk.CENTER)
self.hotkey_text.insert(
0,
# No hotkey/shortcut currently defined
@ -741,7 +742,7 @@ class PreferencesDialog(tk.Toplevel):
appearance_frame, text=_('Dark'), variable=self.theme, value=1, command=self.themevarchanged
).grid(columnspan=3, padx=self.BUTTONX, sticky=tk.W, row=row.get())
if platform == 'win32':
if sys.platform == 'win32':
nb.Radiobutton(
appearance_frame,
# LANG: Label for 'Transparent' theme radio button
@ -870,7 +871,7 @@ class PreferencesDialog(tk.Toplevel):
)
self.ontop_button.grid(columnspan=3, padx=self.BUTTONX, sticky=tk.W, row=row.get()) # Appearance setting
if platform == 'win32':
if sys.platform == 'win32':
nb.Checkbutton(
appearance_frame,
# LANG: Appearance option for Windows "minimize to system tray"
@ -997,7 +998,7 @@ class PreferencesDialog(tk.Toplevel):
def tabchanged(self, event: tk.Event) -> None:
"""Handle preferences active tab changing."""
self.outvarchanged()
if platform == 'darwin':
if sys.platform == 'darwin':
# Hack to recompute size so that buttons show up under Mojave
notebook = event.widget
frame = self.nametowidget(notebook.winfo_parent())
@ -1027,9 +1028,8 @@ class PreferencesDialog(tk.Toplevel):
# If encoding isn't UTF-8 we can't use the tkinter dialog
current_locale = locale.getlocale(locale.LC_CTYPE)
from sys import platform as sys_platform
directory = None
if sys_platform == 'win32' and current_locale[1] not in ('utf8', 'UTF8', 'utf-8', 'UTF-8'):
if sys.platform == 'win32' and current_locale[1] not in ('utf8', 'UTF8', 'utf-8', 'UTF-8'):
def browsecallback(hwnd, uMsg, lParam, lpData): # noqa: N803 # Windows API convention
# set initial folder
if uMsg == BFFM_INITIALIZED and lpData:
@ -1075,7 +1075,7 @@ class PreferencesDialog(tk.Toplevel):
# TODO: This is awful.
entryfield['state'] = tk.NORMAL # must be writable to update
entryfield.delete(0, tk.END)
if platform == 'win32':
if sys.platform == 'win32':
start = len(config.home.split('\\')) if pathvar.get().lower().startswith(config.home.lower()) else 0
display = []
components = normpath(pathvar.get()).split('\\')
@ -1096,7 +1096,7 @@ class PreferencesDialog(tk.Toplevel):
entryfield.insert(0, '\\'.join(display))
# None if path doesn't exist
elif platform == 'darwin' and NSFileManager.defaultManager().componentsToDisplayForPath_(pathvar.get()):
elif sys.platform == 'darwin' and NSFileManager.defaultManager().componentsToDisplayForPath_(pathvar.get()):
if pathvar.get().startswith(config.home):
display = ['~'] + NSFileManager.defaultManager().componentsToDisplayForPath_(pathvar.get())[
len(NSFileManager.defaultManager().componentsToDisplayForPath_(config.home)):
@ -1236,7 +1236,7 @@ class PreferencesDialog(tk.Toplevel):
else:
config.set('journaldir', logdir)
if platform in ('darwin', 'win32'):
if sys.platform in ('darwin', 'win32'):
config.set('hotkey_code', self.hotkey_code)
config.set('hotkey_mods', self.hotkey_mods)
config.set('hotkey_always', int(not self.hotkey_only.get()))
@ -1282,7 +1282,7 @@ class PreferencesDialog(tk.Toplevel):
self.parent.wm_attributes('-topmost', 1 if config.get_int('always_ontop') else 0)
self.destroy()
if platform == 'darwin':
if sys.platform == 'darwin':
def enableshortcuts(self) -> None:
"""Set up macOS preferences shortcut."""
self.apply()

View File

@ -3,10 +3,13 @@ max_line_length = 120
[tool.isort]
multi_line_output = 5
line_length = 119
line_length = 119
[tool.pytest.ini_options]
testpaths = ["tests"] # Search for tests in tests/
[tool.coverage.run]
omit = ["venv/*"] # when running pytest --cov, dont report coverage in venv directories
omit = ["venv/*"] # when running pytest --cov, dont report coverage in venv directories
[tool.pyright]
# pythonPlatform = 'Darwin'

View File

@ -1,9 +1,9 @@
"""CMDR Status information."""
import csv
import json
import sys
import tkinter
import tkinter as tk
from sys import platform
from tkinter import ttk
from typing import TYPE_CHECKING, Any, AnyStr, Callable, Dict, List, NamedTuple, Optional, Sequence, cast
@ -20,11 +20,9 @@ logger = EDMCLogging.get_main_logger()
if TYPE_CHECKING:
def _(x: str) -> str: ...
if platform == 'win32':
if sys.platform == 'win32':
import ctypes
from ctypes.wintypes import HWND, POINT, RECT, SIZE, UINT
if TYPE_CHECKING:
import ctypes.windll # type: ignore # Fake this into existing, its really a magic dll thing
try:
CalculatePopupWindowPosition = ctypes.windll.user32.CalculatePopupWindowPosition
@ -372,15 +370,15 @@ class StatsResults(tk.Toplevel):
self.transient(parent)
# position over parent
if platform != 'darwin' or parent.winfo_rooty() > 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if sys.platform != 'darwin' or parent.winfo_rooty() > 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
self.geometry(f"+{parent.winfo_rootx()}+{parent.winfo_rooty()}")
# remove decoration
self.resizable(tk.FALSE, tk.FALSE)
if platform == 'win32':
if sys.platform == 'win32':
self.attributes('-toolwindow', tk.TRUE)
elif platform == 'darwin':
elif sys.platform == 'darwin':
# http://wiki.tcl.tk/13428
parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility')
@ -421,7 +419,7 @@ class StatsResults(tk.Toplevel):
ttk.Frame(page).grid(pady=5) # bottom spacer
notebook.add(page, text=_('Ships')) # LANG: Status dialog title
if platform != 'darwin':
if sys.platform != 'darwin':
buttonframe = ttk.Frame(frame)
buttonframe.grid(padx=10, pady=(0, 10), sticky=tk.NSEW) # type: ignore # the tuple is supported
buttonframe.columnconfigure(0, weight=1)
@ -433,7 +431,7 @@ class StatsResults(tk.Toplevel):
self.grab_set()
# Ensure fully on-screen
if platform == 'win32' and CalculatePopupWindowPosition:
if sys.platform == 'win32' and CalculatePopupWindowPosition:
position = RECT()
GetWindowRect(GetParent(self.winfo_id()), position)
if CalculatePopupWindowPosition(

View File

@ -4,7 +4,6 @@ import warnings
from configparser import NoOptionError
from os import getenv, makedirs, mkdir, pardir
from os.path import dirname, expanduser, isdir, join, normpath
from sys import platform
from typing import TYPE_CHECKING, Optional, Union
from config import applongname, appname, update_interval
@ -12,13 +11,13 @@ from EDMCLogging import get_main_logger
logger = get_main_logger()
if platform == 'darwin':
if sys.platform == 'darwin':
from Foundation import ( # type: ignore
NSApplicationSupportDirectory, NSBundle, NSDocumentDirectory, NSSearchPathForDirectoriesInDomains,
NSUserDefaults, NSUserDomainMask
)
elif platform == 'win32':
elif sys.platform == 'win32':
import ctypes
import uuid
from ctypes.wintypes import DWORD, HANDLE, HKEY, LONG, LPCVOID, LPCWSTR
@ -90,7 +89,7 @@ elif platform == 'win32':
CoTaskMemFree(buf) # and free original
return retval
elif platform == 'linux':
elif sys.platform == 'linux':
import codecs
from configparser import RawConfigParser
@ -114,7 +113,7 @@ class OldConfig():
OUT_SYS_EDDN = 2048
OUT_SYS_DELAY = 4096
if platform == 'darwin': # noqa: C901 # It's gating *all* the functions
if sys.platform == 'darwin': # noqa: C901 # It's gating *all* the functions
def __init__(self):
self.app_dir = join(
@ -199,7 +198,7 @@ class OldConfig():
self.save()
self.defaults = None
elif platform == 'win32':
elif sys.platform == 'win32':
def __init__(self):
self.app_dir = join(known_folder_path(FOLDERID_LocalAppData), appname) # type: ignore # Not going to change
@ -362,7 +361,7 @@ class OldConfig():
RegCloseKey(self.hkey)
self.hkey = None
elif platform == 'linux':
elif sys.platform == 'linux':
SECTION = 'config'
def __init__(self):

View File

@ -1,4 +1,12 @@
"""Test the config system."""
"""
Test the config system.
Note: These tests to arbitrary reads and writes to an existing config, including
key deletions. Said modifications are to keys that are generated internally.
Most of these tests are parity tests with the "old" config, and likely one day can be
entirely removed.
"""
from __future__ import annotations
import contextlib
@ -19,7 +27,7 @@ print(sys.path)
from _old_config import old_config # noqa: E402
from config import LinuxConfig, config # noqa: E402
from config import config # noqa: E402
def _fuzz_list(length: int) -> List[str]:
@ -77,6 +85,11 @@ class TestNewConfig:
def __update_linuxconfig(self) -> None:
"""On linux config uses ConfigParser, which doesn't update from disk changes. Force the update here."""
if sys.platform != 'linux':
return
from config.linux import LinuxConfig
if isinstance(config, LinuxConfig) and config.config is not None:
config.config.read(config.filename)
@ -163,6 +176,10 @@ class TestOldNewConfig:
def __update_linuxconfig(self) -> None:
"""On linux config uses ConfigParser, which doesn't update from disk changes. Force the update here."""
if sys.platform != 'linux':
return
from config.linux import LinuxConfig
if isinstance(config, LinuxConfig) and config.config is not None:
config.config.read(config.filename)

206
theme.py
View File

@ -6,9 +6,9 @@
#
import os
import sys
import tkinter as tk
from os.path import join
from sys import platform
from tkinter import font as tkFont
from tkinter import ttk
@ -18,20 +18,20 @@ from ttkHyperlinkLabel import HyperlinkLabel
if __debug__:
from traceback import print_exc
if platform == "linux":
if sys.platform == "linux":
from ctypes import POINTER, Structure, byref, c_char_p, c_int, c_long, c_uint, c_ulong, c_void_p, cdll
if platform == 'win32':
if sys.platform == 'win32':
import ctypes
from ctypes.wintypes import DWORD, LPCVOID, LPCWSTR
AddFontResourceEx = ctypes.windll.gdi32.AddFontResourceExW
AddFontResourceEx.restypes = [LPCWSTR, DWORD, LPCVOID]
FR_PRIVATE = 0x10
FR_PRIVATE = 0x10
FR_NOT_ENUM = 0x20
AddFontResourceEx(join(config.respath, u'EUROCAPS.TTF'), FR_PRIVATE, 0)
elif platform == 'linux':
elif sys.platform == 'linux':
# pyright: reportUnboundVariable=false
XID = c_ulong # from X.h: typedef unsigned long XID
Window = XID
@ -40,7 +40,7 @@ elif platform == 'linux':
PropModeReplace = 0
PropModePrepend = 1
PropModeAppend = 2
PropModeAppend = 2
# From xprops.h
MWM_HINTS_FUNCTIONS = 1 << 0
@ -69,16 +69,17 @@ elif platform == 'linux':
('input_mode', c_long),
('status', c_ulong),
]
# workaround for https://github.com/EDCD/EDMarketConnector/issues/568
if not os.getenv("EDMC_NO_UI") :
if not os.getenv("EDMC_NO_UI"):
try:
xlib = cdll.LoadLibrary('libX11.so.6')
XInternAtom = xlib.XInternAtom
XInternAtom.argtypes = [POINTER(Display), c_char_p, c_int]
XInternAtom.restype = Atom
XChangeProperty = xlib.XChangeProperty
XChangeProperty.argtypes = [POINTER(Display), Window, Atom, Atom, c_int, c_int, POINTER(MotifWmHints), c_int]
XChangeProperty.argtypes = [POINTER(Display), Window, Atom, Atom, c_int,
c_int, POINTER(MotifWmHints), c_int]
XChangeProperty.restype = c_int
XFlush = xlib.XFlush
XFlush.argtypes = [POINTER(Display)]
@ -87,29 +88,31 @@ elif platform == 'linux':
XOpenDisplay.argtypes = [c_char_p]
XOpenDisplay.restype = POINTER(Display)
XQueryTree = xlib.XQueryTree
XQueryTree.argtypes = [POINTER(Display), Window, POINTER(Window), POINTER(Window), POINTER(Window), POINTER(c_uint)]
XQueryTree.argtypes = [POINTER(Display), Window, POINTER(
Window), POINTER(Window), POINTER(Window), POINTER(c_uint)]
XQueryTree.restype = c_int
dpy = xlib.XOpenDisplay(None)
if not dpy:
raise Exception("Can't find your display, can't continue")
motif_wm_hints_property = XInternAtom(dpy, b'_MOTIF_WM_HINTS', False)
motif_wm_hints_normal = MotifWmHints(MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS,
MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE,
MWM_DECOR_BORDER | MWM_DECOR_RESIZEH | MWM_DECOR_TITLE | MWM_DECOR_MENU | MWM_DECOR_MINIMIZE,
0, 0)
motif_wm_hints_dark = MotifWmHints(MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS,
MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE,
0, 0, 0)
MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE,
MWM_DECOR_BORDER | MWM_DECOR_RESIZEH | MWM_DECOR_TITLE | MWM_DECOR_MENU | MWM_DECOR_MINIMIZE,
0, 0)
motif_wm_hints_dark = MotifWmHints(MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS,
MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE,
0, 0, 0)
except:
if __debug__: print_exc()
if __debug__:
print_exc()
dpy = None
class _Theme(object):
def __init__(self):
self.active = None # Starts out with no theme
self.active = None # Starts out with no theme
self.minwidth = None
self.widgets = {}
self.widgets_pair = []
@ -124,18 +127,18 @@ class _Theme(object):
if not self.defaults:
# Can't initialise this til window is created # Windows, MacOS
self.defaults = {
'fg' : tk.Label()['foreground'], # SystemButtonText, systemButtonText
'bg' : tk.Label()['background'], # SystemButtonFace, White
'font' : tk.Label()['font'], # TkDefaultFont
'bitmapfg' : tk.BitmapImage()['foreground'], # '-foreground {} {} #000000 #000000'
'bitmapbg' : tk.BitmapImage()['background'], # '-background {} {} {} {}'
'entryfg' : tk.Entry()['foreground'], # SystemWindowText, Black
'entrybg' : tk.Entry()['background'], # SystemWindow, systemWindowBody
'entryfont' : tk.Entry()['font'], # TkTextFont
'frame' : tk.Frame()['background'], # SystemButtonFace, systemWindowBody
'menufg' : tk.Menu()['foreground'], # SystemMenuText,
'menubg' : tk.Menu()['background'], # SystemMenu,
'menufont' : tk.Menu()['font'], # TkTextFont
'fg': tk.Label()['foreground'], # SystemButtonText, systemButtonText
'bg': tk.Label()['background'], # SystemButtonFace, White
'font': tk.Label()['font'], # TkDefaultFont
'bitmapfg': tk.BitmapImage()['foreground'], # '-foreground {} {} #000000 #000000'
'bitmapbg': tk.BitmapImage()['background'], # '-background {} {} {} {}'
'entryfg': tk.Entry()['foreground'], # SystemWindowText, Black
'entrybg': tk.Entry()['background'], # SystemWindow, systemWindowBody
'entryfont': tk.Entry()['font'], # TkTextFont
'frame': tk.Frame()['background'], # SystemButtonFace, systemWindowBody
'menufg': tk.Menu()['foreground'], # SystemMenuText,
'menubg': tk.Menu()['background'], # SystemMenu,
'menufont': tk.Menu()['font'], # TkTextFont
}
if widget not in self.widgets:
@ -189,26 +192,27 @@ class _Theme(object):
def _enter(self, event, image):
widget = event.widget
if widget and widget['state'] != tk.DISABLED:
widget.configure(state = tk.ACTIVE)
widget.configure(state=tk.ACTIVE)
if image:
image.configure(foreground = self.current['activeforeground'], background = self.current['activebackground'])
image.configure(foreground=self.current['activeforeground'],
background=self.current['activebackground'])
def _leave(self, event, image):
widget = event.widget
if widget and widget['state'] != tk.DISABLED:
widget.configure(state = tk.NORMAL)
widget.configure(state=tk.NORMAL)
if image:
image.configure(foreground = self.current['foreground'], background = self.current['background'])
image.configure(foreground=self.current['foreground'], background=self.current['background'])
# Set up colors
def _colors(self, root, theme):
style = ttk.Style()
if platform == 'linux':
if sys.platform == 'linux':
style.theme_use('clam')
# Default dark theme colors
if not config.get_str('dark_text'):
config.set('dark_text', '#ff8000') # "Tangerine" in OSX color picker
config.set('dark_text', '#ff8000') # "Tangerine" in OSX color picker
if not config.get_str('dark_highlight'):
config.set('dark_highlight', 'white')
@ -216,40 +220,40 @@ class _Theme(object):
# Dark
(r, g, b) = root.winfo_rgb(config.get_str('dark_text'))
self.current = {
'background' : 'grey4', # OSX inactive dark titlebar color
'foreground' : config.get_str('dark_text'),
'activebackground' : config.get_str('dark_text'),
'activeforeground' : 'grey4',
'disabledforeground' : '#%02x%02x%02x' % (int(r/384), int(g/384), int(b/384)),
'highlight' : config.get_str('dark_highlight'),
'background': 'grey4', # OSX inactive dark titlebar color
'foreground': config.get_str('dark_text'),
'activebackground': config.get_str('dark_text'),
'activeforeground': 'grey4',
'disabledforeground': '#%02x%02x%02x' % (int(r/384), int(g/384), int(b/384)),
'highlight': config.get_str('dark_highlight'),
# Font only supports Latin 1 / Supplement / Extended, and a few General Punctuation and Mathematical Operators
# LANG: Label for commander name in main window
'font' : (theme > 1 and not 0x250 < ord(_('Cmdr')[0]) < 0x3000 and
tkFont.Font(family='Euro Caps', size=10, weight=tkFont.NORMAL) or
'TkDefaultFont'),
'font': (theme > 1 and not 0x250 < ord(_('Cmdr')[0]) < 0x3000 and
tkFont.Font(family='Euro Caps', size=10, weight=tkFont.NORMAL) or
'TkDefaultFont'),
}
else:
# (Mostly) system colors
style = ttk.Style()
self.current = {
'background' : (platform == 'darwin' and 'systemMovableModalBackground' or
style.lookup('TLabel', 'background')),
'foreground' : style.lookup('TLabel', 'foreground'),
'activebackground' : (platform == 'win32' and 'SystemHighlight' or
style.lookup('TLabel', 'background', ['active'])),
'activeforeground' : (platform == 'win32' and 'SystemHighlightText' or
style.lookup('TLabel', 'foreground', ['active'])),
'disabledforeground' : style.lookup('TLabel', 'foreground', ['disabled']),
'highlight' : 'blue',
'font' : 'TkDefaultFont',
'background': (sys.platform == 'darwin' and 'systemMovableModalBackground' or
style.lookup('TLabel', 'background')),
'foreground': style.lookup('TLabel', 'foreground'),
'activebackground': (sys.platform == 'win32' and 'SystemHighlight' or
style.lookup('TLabel', 'background', ['active'])),
'activeforeground': (sys.platform == 'win32' and 'SystemHighlightText' or
style.lookup('TLabel', 'foreground', ['active'])),
'disabledforeground': style.lookup('TLabel', 'foreground', ['disabled']),
'highlight': 'blue',
'font': 'TkDefaultFont',
}
# Apply current theme to a widget and its children, and register it for future updates
def update(self, widget):
assert isinstance(widget, tk.Widget) or isinstance(widget, tk.BitmapImage), widget
if not self.current:
return # No need to call this for widgets created in plugin_app()
return # No need to call this for widgets created in plugin_app()
self.register(widget)
self._update_widget(widget)
if isinstance(widget, tk.Frame) or isinstance(widget, ttk.Frame):
@ -258,56 +262,57 @@ class _Theme(object):
# Apply current theme to a single widget
def _update_widget(self, widget):
assert widget in self.widgets, '%s %s "%s"' %(widget.winfo_class(), widget, 'text' in widget.keys() and widget['text'])
assert widget in self.widgets, '%s %s "%s"' % (
widget.winfo_class(), widget, 'text' in widget.keys() and widget['text'])
attribs = self.widgets.get(widget, [])
if isinstance(widget, tk.BitmapImage):
# not a widget
if 'fg' not in attribs:
widget.configure(foreground = self.current['foreground']),
widget.configure(foreground=self.current['foreground']),
if 'bg' not in attribs:
widget.configure(background = self.current['background'])
widget.configure(background=self.current['background'])
elif 'cursor' in widget.keys() and str(widget['cursor']) not in ['', 'arrow']:
# Hack - highlight widgets like HyperlinkLabel with a non-default cursor
if 'fg' not in attribs:
widget.configure(foreground = self.current['highlight']),
if 'insertbackground' in widget.keys(): # tk.Entry
widget.configure(insertbackground = self.current['foreground']),
widget.configure(foreground=self.current['highlight']),
if 'insertbackground' in widget.keys(): # tk.Entry
widget.configure(insertbackground=self.current['foreground']),
if 'bg' not in attribs:
widget.configure(background = self.current['background'])
if 'highlightbackground' in widget.keys(): # tk.Entry
widget.configure(highlightbackground = self.current['background'])
widget.configure(background=self.current['background'])
if 'highlightbackground' in widget.keys(): # tk.Entry
widget.configure(highlightbackground=self.current['background'])
if 'font' not in attribs:
widget.configure(font = self.current['font'])
widget.configure(font=self.current['font'])
elif 'activeforeground' in widget.keys():
# e.g. tk.Button, tk.Label, tk.Menu
if 'fg' not in attribs:
widget.configure(foreground = self.current['foreground'],
activeforeground = self.current['activeforeground'],
disabledforeground = self.current['disabledforeground'])
widget.configure(foreground=self.current['foreground'],
activeforeground=self.current['activeforeground'],
disabledforeground=self.current['disabledforeground'])
if 'bg' not in attribs:
widget.configure(background = self.current['background'],
activebackground = self.current['activebackground'])
if platform == 'darwin' and isinstance(widget, tk.Button):
widget.configure(highlightbackground = self.current['background'])
widget.configure(background=self.current['background'],
activebackground=self.current['activebackground'])
if sys.platform == 'darwin' and isinstance(widget, tk.Button):
widget.configure(highlightbackground=self.current['background'])
if 'font' not in attribs:
widget.configure(font = self.current['font'])
widget.configure(font=self.current['font'])
elif 'foreground' in widget.keys():
# e.g. ttk.Label
if 'fg' not in attribs:
widget.configure(foreground = self.current['foreground']),
widget.configure(foreground=self.current['foreground']),
if 'bg' not in attribs:
widget.configure(background = self.current['background'])
widget.configure(background=self.current['background'])
if 'font' not in attribs:
widget.configure(font = self.current['font'])
widget.configure(font=self.current['font'])
elif 'background' in widget.keys() or isinstance(widget, tk.Canvas):
# e.g. Frame, Canvas
if 'bg' not in attribs:
widget.configure(background = self.current['background'],
highlightbackground = self.current['disabledforeground'])
widget.configure(background=self.current['background'],
highlightbackground=self.current['disabledforeground'])
# Apply configured theme
def apply(self, root):
theme = config.get_int('theme')
@ -316,7 +321,7 @@ class _Theme(object):
# Apply colors
for widget in set(self.widgets):
if isinstance(widget, tk.Widget) and not widget.winfo_exists():
self.widgets.pop(widget) # has been destroyed
self.widgets.pop(widget) # has been destroyed
else:
self._update_widget(widget)
@ -334,58 +339,61 @@ class _Theme(object):
pair[theme].grid(**gridopts)
if self.active == theme:
return # Don't need to mess with the window manager
return # Don't need to mess with the window manager
else:
self.active = theme
if platform == 'darwin':
if sys.platform == 'darwin':
from AppKit import NSAppearance, NSApplication, NSMiniaturizableWindowMask, NSResizableWindowMask
root.update_idletasks() # need main window to be created
root.update_idletasks() # need main window to be created
appearance = NSAppearance.appearanceNamed_(theme and
'NSAppearanceNameDarkAqua' or
'NSAppearanceNameAqua')
for window in NSApplication.sharedApplication().windows():
window.setStyleMask_(window.styleMask() & ~(NSMiniaturizableWindowMask | NSResizableWindowMask)) # disable zoom
window.setStyleMask_(window.styleMask() & ~(
NSMiniaturizableWindowMask | NSResizableWindowMask)) # disable zoom
window.setAppearance_(appearance)
elif platform == 'win32':
elif sys.platform == 'win32':
GWL_STYLE = -16
WS_MAXIMIZEBOX = 0x00010000
WS_MAXIMIZEBOX = 0x00010000
# tk8.5.9/win/tkWinWm.c:342
GWL_EXSTYLE = -20
WS_EX_APPWINDOW = 0x00040000
WS_EX_LAYERED = 0x00080000
WS_EX_APPWINDOW = 0x00040000
WS_EX_LAYERED = 0x00080000
GetWindowLongW = ctypes.windll.user32.GetWindowLongW
SetWindowLongW = ctypes.windll.user32.SetWindowLongW
root.overrideredirect(theme and 1 or 0)
root.attributes("-transparentcolor", theme > 1 and 'grey4' or '')
root.withdraw()
root.update_idletasks() # Size and windows styles get recalculated here
root.update_idletasks() # Size and windows styles get recalculated here
hwnd = ctypes.windll.user32.GetParent(root.winfo_id())
SetWindowLongW(hwnd, GWL_STYLE, GetWindowLongW(hwnd, GWL_STYLE) & ~WS_MAXIMIZEBOX) # disable maximize
SetWindowLongW(hwnd, GWL_EXSTYLE, theme > 1 and WS_EX_APPWINDOW|WS_EX_LAYERED or WS_EX_APPWINDOW) # Add to taskbar
SetWindowLongW(hwnd, GWL_STYLE, GetWindowLongW(hwnd, GWL_STYLE) & ~WS_MAXIMIZEBOX) # disable maximize
SetWindowLongW(hwnd, GWL_EXSTYLE, theme > 1 and WS_EX_APPWINDOW |
WS_EX_LAYERED or WS_EX_APPWINDOW) # Add to taskbar
root.deiconify()
root.wait_visibility() # need main window to be displayed before returning
root.wait_visibility() # need main window to be displayed before returning
else:
root.withdraw()
root.update_idletasks() # Size gets recalculated here
root.update_idletasks() # Size gets recalculated here
if dpy:
xroot = Window()
parent = Window()
children = Window()
nchildren = c_uint()
XQueryTree(dpy, root.winfo_id(), byref(xroot), byref(parent), byref(children), byref(nchildren))
XChangeProperty(dpy, parent, motif_wm_hints_property, motif_wm_hints_property, 32, PropModeReplace, theme and motif_wm_hints_dark or motif_wm_hints_normal, 5)
XChangeProperty(dpy, parent, motif_wm_hints_property, motif_wm_hints_property, 32,
PropModeReplace, theme and motif_wm_hints_dark or motif_wm_hints_normal, 5)
XFlush(dpy)
else:
root.overrideredirect(theme and 1 or 0)
root.deiconify()
root.wait_visibility() # need main window to be displayed before returning
root.wait_visibility() # need main window to be displayed before returning
if not self.minwidth:
self.minwidth = root.winfo_width() # Minimum width = width on first creation
self.minwidth = root.winfo_width() # Minimum width = width on first creation
root.minsize(self.minwidth, -1)

View File

@ -1,14 +1,12 @@
import sys
import tkinter as tk
import webbrowser
from sys import platform
from tkinter import font as tkFont
from tkinter import ttk
if platform == 'win32':
if sys.platform == 'win32':
import subprocess
from winreg import (
HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, CloseKey, OpenKeyEx, QueryValueEx
)
from winreg import HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, CloseKey, OpenKeyEx, QueryValueEx
# A clickable ttk Label
#
@ -18,19 +16,22 @@ if platform == 'win32':
# popup_copy: Whether right-click on non-empty label text pops up a context menu with a 'Copy' option. Defaults to no context menu. If popup_copy is a function it will be called with the current label text and should return a boolean.
#
# May be imported by plugins
class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object):
class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object):
def __init__(self, master=None, **kw):
self.url = 'url' in kw and kw.pop('url') or None
self.popup_copy = kw.pop('popup_copy', False)
self.underline = kw.pop('underline', None) # override ttk.Label's underline
self.underline = kw.pop('underline', None) # override ttk.Label's underline
self.foreground = kw.get('foreground') or 'blue'
self.disabledforeground = kw.pop('disabledforeground', ttk.Style().lookup('TLabel', 'foreground', ('disabled',))) # ttk.Label doesn't support disabledforeground option
self.disabledforeground = kw.pop('disabledforeground', ttk.Style().lookup(
'TLabel', 'foreground', ('disabled',))) # ttk.Label doesn't support disabledforeground option
if platform == 'darwin':
if sys.platform == 'darwin':
# Use tk.Label 'cos can't set ttk.Label background - http://www.tkdocs.com/tutorial/styles.html#whydifficult
kw['background'] = kw.pop('background', 'systemDialogBackgroundActive')
kw['anchor'] = kw.pop('anchor', tk.W) # like ttk.Label
kw['anchor'] = kw.pop('anchor', tk.W) # like ttk.Label
tk.Label.__init__(self, master, **kw)
else:
ttk.Label.__init__(self, master, **kw)
@ -39,16 +40,16 @@ class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object):
self.menu = tk.Menu(None, tearoff=tk.FALSE)
# LANG: Label for 'Copy' as in 'Copy and Paste'
self.menu.add_command(label=_('Copy'), command = self.copy) # As in Copy and Paste
self.bind(platform == 'darwin' and '<Button-2>' or '<Button-3>', self._contextmenu)
self.menu.add_command(label=_('Copy'), command=self.copy) # As in Copy and Paste
self.bind(sys.platform == 'darwin' and '<Button-2>' or '<Button-3>', self._contextmenu)
self.bind('<Enter>', self._enter)
self.bind('<Leave>', self._leave)
# set up initial appearance
self.configure(state = kw.get('state', tk.NORMAL),
text = kw.get('text'),
font = kw.get('font', ttk.Style().lookup('TLabel', 'font')))
self.configure(state=kw.get('state', tk.NORMAL),
text=kw.get('text'),
font=kw.get('font', ttk.Style().lookup('TLabel', 'font')))
# Change cursor and appearance depending on state and text
def configure(self, cnf=None, **kw):
@ -70,17 +71,18 @@ class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object):
if 'font' in kw:
self.font_n = kw['font']
self.font_u = tkFont.Font(font = self.font_n)
self.font_u.configure(underline = True)
self.font_u = tkFont.Font(font=self.font_n)
self.font_u.configure(underline=True)
kw['font'] = self.underline is True and self.font_u or self.font_n
if 'cursor' not in kw:
if (kw['state'] if 'state' in kw else str(self['state'])) == tk.DISABLED:
kw['cursor'] = 'arrow' # System default
kw['cursor'] = 'arrow' # System default
elif self.url and (kw['text'] if 'text' in kw else self['text']):
kw['cursor'] = platform=='darwin' and 'pointinghand' or 'hand2'
kw['cursor'] = sys.platform == 'darwin' and 'pointinghand' or 'hand2'
else:
kw['cursor'] = (platform=='darwin' and 'notallowed') or (platform=='win32' and 'no') or 'circle'
kw['cursor'] = (sys.platform == 'darwin' and 'notallowed') or (
sys.platform == 'win32' and 'no') or 'circle'
super(HyperlinkLabel, self).configure(cnf, **kw)
@ -89,22 +91,22 @@ class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object):
def _enter(self, event):
if self.url and self.underline is not False and str(self['state']) != tk.DISABLED:
super(HyperlinkLabel, self).configure(font = self.font_u)
super(HyperlinkLabel, self).configure(font=self.font_u)
def _leave(self, event):
if not self.underline:
super(HyperlinkLabel, self).configure(font = self.font_n)
super(HyperlinkLabel, self).configure(font=self.font_n)
def _click(self, event):
if self.url and self['text'] and str(self['state']) != tk.DISABLED:
url = self.url(self['text']) if callable(self.url) else self.url
if url:
self._leave(event) # Remove underline before we change window to browser
self._leave(event) # Remove underline before we change window to browser
openurl(url)
def _contextmenu(self, event):
if self['text'] and (self.popup_copy(self['text']) if callable(self.popup_copy) else self.popup_copy):
self.menu.post(platform == 'darwin' and event.x_root + 1 or event.x_root, event.y_root)
self.menu.post(sys.platform == 'darwin' and event.x_root + 1 or event.x_root, event.y_root)
def copy(self):
self.clipboard_clear()
@ -112,13 +114,14 @@ class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object):
def openurl(url):
if platform == 'win32':
if sys.platform == 'win32':
# On Windows webbrowser.open calls os.startfile which calls ShellExecute which can't handle long arguments,
# so discover and launch the browser directly.
# https://blogs.msdn.microsoft.com/oldnewthing/20031210-00/?p=41553
try:
hkey = OpenKeyEx(HKEY_CURRENT_USER, r'Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice')
hkey = OpenKeyEx(HKEY_CURRENT_USER,
r'Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice')
(value, typ) = QueryValueEx(hkey, 'ProgId')
CloseKey(hkey)
if value in ['IE.HTTP', 'AppXq0fevzme2pys62n3e0fbqa7peapykr8v']:
@ -128,7 +131,7 @@ def openurl(url):
else:
cls = value
except:
cls = 'https'
cls = 'https'
if cls:
try: