diff --git a/ChangeLog.md b/ChangeLog.md index 5dd6be67..c4d24e42 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -6,7 +6,7 @@ This is the master changelog for Elite Dangerous Market Connector. Entries are in the source (not distributed with the Windows installer) for the currently used version. --- -Release 5.14.0 +Release 6.0.0 === This is the WORK IN PROGRESS changelog for upcoming release 5.14. All features are subject to change. @@ -22,6 +22,17 @@ This is the WORK IN PROGRESS changelog for upcoming release 5.14. All features a * Set new deprecation warnings using Python 3.13's new Deprecation decorator. * Fixed a bug where "en" would not be included as a valid language and raise a KeyError +Release 5.13.3 +=== +This release contains a bugfix for a race condition that could occur if plugin setup took too long for the journal monitor thread. + +This often resulted in users seeing "Awaiting CMDR Login" even when already in the game. + +This will likely be the last release before 6.0.0. + +**Changes and Enhancements** +* Fixes a race condition in journal reading. + **Plugin Developers** * nb.Entry is deprecated, and is slated for removal in 6.0 or later. Please migrate to nb.EntryMenu * nb.ColoredButton is deprecated, and is slated for removal in 6.0 or later. Please migrate to tk.Button diff --git a/EDMarketConnector.py b/EDMarketConnector.py old mode 100755 new mode 100644 diff --git a/L10n/en.template b/L10n/en.template index 98ae2ba9..faac3259 100644 --- a/L10n/en.template +++ b/L10n/en.template @@ -498,6 +498,15 @@ /* prefs.py: Catch & Record Profiler Errors; */ "Error in System Profiler" = "Error in System Profiler"; +/* prefs.py: Disabling a Plugin. */ +"Disabling plugin {PLUGIN}. This will cause a restart. Click OK to continue..." = "Disabling plugin {PLUGIN}. This will cause a restart. Click OK to continue..."; + +/* prefs.py: Enable a Disabled Plugin */ +"Enable" = "Enable"; + +/* prefs.py: Disable an Enabled Plugin */ +"Disable" = "Disable"; + /* stats.py: Cmdr stats; In files: stats.py:58; */ "Balance" = "Balance"; diff --git a/myNotebook.py b/myNotebook.py index 8541a5f9..46f79622 100644 --- a/myNotebook.py +++ b/myNotebook.py @@ -157,3 +157,183 @@ class OptionMenu(ttk.OptionMenu): # Workaround for https://bugs.python.org/issue25684 for i in range(0, self['menu'].index('end') + 1): self['menu'].entryconfig(i, variable=variable) + + +class ScrollableNotebook(Notebook): + """ + ScrollableNotebook – A tab bar that scrolls horizontally when there are too many tabs. + + Based off ttkScrollableNotebook by @muhammeteminturgut. + https://github.com/muhammeteminturgut/ttkScrollableNotebook + """ + + def __init__( + self, + master: ttk.Frame | None = None, + tabmenu: bool = False, + *args, + **kwargs + ) -> None: + super().__init__(master, **kwargs) + + # Sliding state + self.xLocation: int = 0 + self.timer: str | None = None + self.menuSpace: int = 50 if tabmenu else 30 + self.contentsManaged: list[tk.Widget] = [] + + # Notebook that holds the actual content widgets + self.notebookContent: ttk.Notebook = ttk.Notebook(self, **kwargs) + self.notebookContent.pack(fill="both", expand=True) + self.notebookContent.bind("", self._reset_slide) + + # Notebook that displays scrollable tabs + self.notebookTab: ttk.Notebook = ttk.Notebook(self, **kwargs) + self.notebookTab.place(x=0, y=0) + self.notebookTab.bind("<>", self._tab_changer) + + # Sliding frame and controls + slide_frame: ttk.Frame = ttk.Frame(self) + slide_frame.place(relx=1.0, x=0, y=1, anchor=tk.NE) + + if tabmenu: + menu_btn = ttk.Label(slide_frame, text="\u2630") + menu_btn.bind("", self._bottom_menu) + menu_btn.pack(side=tk.RIGHT) + + left_arrow = ttk.Label(slide_frame, text=" \u276e") + left_arrow.bind("", self._left_slide_start) + left_arrow.bind("", self._slide_stop) + left_arrow.pack(side=tk.LEFT) + right_arrow = ttk.Label(slide_frame, text=" \u276f") + right_arrow.bind("", self._right_slide_start) + right_arrow.bind("", self._slide_stop) + right_arrow.pack(side=tk.RIGHT) + + # Outer Notebook geometry + self.grid(padx=10, pady=10, sticky=tk.NSEW) + + def _bottom_menu(self, event: tk.Event) -> None: + menu = tk.Menu(self, tearoff=0) + for tab in self.notebookTab.tabs(): + label = self.notebookTab.tab(tab, option="text") + menu.add_command(label=label, command=lambda t=tab: self.select(t)) # type: ignore + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() + + def _tab_changer(self, event: tk.Event) -> None: + try: + idx = self.notebookTab.index("current") + self.notebookContent.select(idx) + except Exception: + pass + + def _right_slide_start(self, event: tk.Event | None = None) -> None: + if self._right_slide(event): + self.timer = self.after(100, self._right_slide_start) + + def _right_slide(self, event: tk.Event | None) -> bool: + tabs_w = self.notebookTab.winfo_width() + content_w = self.notebookContent.winfo_width() + + if tabs_w > content_w - self.menuSpace: + remaining = content_w - (tabs_w + self.notebookTab.winfo_x()) + if remaining <= self.menuSpace + 5: + self.xLocation -= 20 + self.notebookTab.place(x=self.xLocation, y=0) + return True + return False + + def _left_slide_start(self, event: tk.Event | None = None) -> None: + if self._left_slide(event): + self.timer = self.after(100, self._left_slide_start) + + def _left_slide(self, event: tk.Event | None) -> bool: + if self.notebookTab.winfo_x() != 0: + self.xLocation += 20 + self.notebookTab.place(x=self.xLocation, y=0) + return True + return False + + def _slide_stop(self, event: tk.Event) -> None: + if self.timer is not None: + self.after_cancel(self.timer) + self.timer = None + + def _reset_slide(self, event: tk.Event | None = None) -> None: + self.notebookTab.place(x=0, y=0) + self.xLocation = 0 + + def add(self, child: tk.Widget, **kwargs) -> None: + """Add content + visual tab.""" + has_tabs = len(self.notebookTab.tabs()) > 0 + + # Content tab properties + content_kwargs = kwargs.copy() + content_kwargs["text"] = "" # content tabs never show text + + if has_tabs: + content_kwargs["state"] = "hidden" + else: + content_kwargs.pop("state", None) + + # Add content widget + self.notebookContent.add(child, **content_kwargs) + + # Add visible tab + self.notebookTab.add(ttk.Frame(self.notebookTab), **kwargs) + self.contentsManaged.append(child) + + def __content_tab_id(self, tab_id: str) -> str: + idx = self.notebookTab.tabs().index(tab_id) + return self.notebookContent.tabs()[idx] + + def forget(self, tab_id: str) -> None: # type: ignore + """Remove a tab from both the visible tab strip and the hidden content notebook.""" + idx = self.notebookTab.index(tab_id) + content_tab = self.__content_tab_id(tab_id) + + self.notebookContent.forget(content_tab) + self.notebookTab.forget(tab_id) + self.contentsManaged[idx].destroy() + self.contentsManaged.pop(idx) + + def hide(self, tab_id: str) -> None: + """Hide a tab without destroying it.""" + self.notebookContent.hide(self.__content_tab_id(tab_id)) + self.notebookTab.hide(tab_id) + + def identify(self, x: int, y: int) -> str | None: # type: ignore + """Identify the visible tab at a given x/y coord.""" + return self.notebookTab.identify(x, y) + + def index(self, tab_id: str) -> int: + """Return the index of a visible tab.""" + return self.notebookTab.index(tab_id) + + def insert(self, pos: int, child: tk.Widget, **kwargs) -> None: + """Insert a new tab at a specified position.""" + self.notebookContent.insert(pos, child, **kwargs) + self.notebookTab.insert(pos, ttk.Frame(self.notebookTab), **kwargs) + + def select(self, tab_id: str) -> None: # type: ignore + """Select a tab.""" + self.notebookTab.select(tab_id) + + def tab(self, tab_id: str, option: str | None = None, **kwargs): + """Get/Set Options for tabs.""" + c_kwargs = kwargs.copy() + c_kwargs["text"] = "" # content tabs never show text + self.notebookContent.tab(self.__content_tab_id(tab_id), **c_kwargs) + return self.notebookTab.tab(tab_id, option=option, **kwargs) + + def tabs(self) -> tuple[str, ...]: + """Tuple of all tabs.""" + return self.notebookTab.tabs() + + def enable_traversal(self) -> None: + """Enable keyboard shortcuts for tabs.""" + self.notebookContent.enable_traversal() + self.notebookTab.enable_traversal() diff --git a/prefs.py b/prefs.py index 5d604d39..71e03087 100644 --- a/prefs.py +++ b/prefs.py @@ -4,6 +4,7 @@ from __future__ import annotations import contextlib import logging +import time from os.path import join, normpath from pathlib import Path import subprocess @@ -275,7 +276,7 @@ class PreferencesDialog(tk.Toplevel): frame.rowconfigure(0, weight=1) frame.rowconfigure(1, weight=0) - notebook: nb.Notebook = nb.Notebook(frame) + notebook: nb.ScrollableNotebook = nb.ScrollableNotebook(frame, tabmenu=True) notebook.bind('<>', self.tabchanged) # Recompute on tab change self.PADX = 10 @@ -324,12 +325,6 @@ class PreferencesDialog(tk.Toplevel): # Set Log Directory self.logfile_loc = Path(config.app_dir_path / 'logs') - # Set minimum size to prevent content cut-off - self.update_idletasks() # Update "requested size" from geometry manager - min_width = self.winfo_reqwidth() - min_height = self.winfo_reqheight() - self.wm_minsize(min_width, min_height) - def __setup_output_tab(self, root_notebook: ttk.Notebook) -> None: output_frame = nb.Frame(root_notebook) output_frame.columnconfigure(0, weight=1) @@ -980,11 +975,19 @@ class PreferencesDialog(tk.Toplevel): ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get()) for plugin in enabled_plugins: + curr_row = row.get() label = nb.Label(plugins_frame, text=plugin.name if plugin.name == plugin.folder else f'{plugin.folder} ({plugin.name})') - label.grid(columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=row.get()) + label.grid(column=0, columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=curr_row) + + btn = ttk.Button( + plugins_frame, + text=tr.tl("Disable"), # LANG: Disable an Enabled Plugin + command=lambda plugin=plugin: self.disable_plugin(plugin) # type: ignore + ) + btn.grid(column=3, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=curr_row) ############################################################ # Show which plugins don't have Python 3.x support @@ -1026,9 +1029,18 @@ class PreferencesDialog(tk.Toplevel): ).grid(padx=self.PADX, pady=self.PADY, sticky=tk.W, row=row.get()) for plugin in disabled_plugins: + curr_row = row.get() nb.Label(plugins_frame, text=plugin.name).grid( - columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=row.get() + column=0, columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=curr_row ) + + btn = ttk.Button( + plugins_frame, + text=tr.tl("Enable"), # LANG: Enable a Disabled Plugin + command=lambda plugin=plugin: self.enable_plugin(plugin) # type: ignore + ) + btn.grid(column=3, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=curr_row) + ############################################################ # Show plugins that failed to load ############################################################ @@ -1050,6 +1062,55 @@ class PreferencesDialog(tk.Toplevel): # LANG: Label on Settings > Plugins tab notebook.add(plugins_frame, text=tr.tl('Plugins')) # Tab heading in settings + def disable_plugin(self, plugin): + """Disable an existing plugin and restart.""" + logger.debug(f"Calling plugin_stop() for {plugin.name}") + if plugin._get_func('plugin_stop'): # Try to stop cleanly. We're going regardless... + plugin.module.plugin_stop() + # LANG: Text of Notification Popup for EDMC Restart + restart_msg = tr.tl( # LANG: Disabling a Plugin + r"Disabling plugin {PLUGIN}. This will cause a restart. Click OK to continue..." + ).format(PLUGIN=plugin.name) + restart_box = tk.messagebox.Message( + title=tr.tl('Restart Required'), # LANG: Title of Notification Popup for EDMC Restart + message=restart_msg, + type=tk.messagebox.OK + ) + restart_box.show() + time.sleep(2) # Give plugin stop time to work. + plug.PLUGINS.remove(plugin) # Remove PLugin from active list + try: + folder = Path(config.plugin_dir_path / plugin.folder) + new_name = f"{folder}.disabled" + folder.rename(new_name) + logger.info(f"Disabled {plugin.name}.") + except FileExistsError: + logger.warning("Unable to disable the plugin!") + return + self.req_restart = True + self.apply() + + def enable_plugin(self, plugin): + """Enable an existing plugin and restart.""" + logger.debug(f"Attempting to enable {plugin.name}") + localfolder = plugin.folder + ".disabled" + folder = Path(config.plugin_dir_path) / localfolder + if not folder.exists(): + logger.warning(f"Folder does not exist: {folder}") + return + new_folder = folder.with_name(plugin.folder.replace(".disabled", "")) + if new_folder.exists(): + logger.warning(f"Target folder already exists: {new_folder}") + return + try: + folder.rename(new_folder) + logger.info(f"Enabled {plugin.name}.") + except Exception as e: + logger.error(f"Failed to enable plugin {plugin.name}: {e}") + return + self.req_restart = True + self.apply() + def cmdrchanged(self, event=None): """ Notify plugins of cmdr change.