Merge pull request #2528 from HullSeals/enhancement/261/disable-plugins

Scrollable Notebook and Plugin Enable/Disable System
This commit is contained in:
David Sangrey authored and GitHub committed 2025-11-18 18:39:23 -05:00
commit 4bd6ec6d7a
5 files changed
+271 -10

No files matched your search

+12 -1
View File
@@ -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
Executable → Regular
View File
File mode changed.
+9
View File
@@ -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";
+180
View File
@@ -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("<Configure>", 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("<<NotebookTabChanged>>", 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("<ButtonPress-1>", self._bottom_menu)
menu_btn.pack(side=tk.RIGHT)
left_arrow = ttk.Label(slide_frame, text=" \u276e")
left_arrow.bind("<ButtonPress-1>", self._left_slide_start)
left_arrow.bind("<ButtonRelease-1>", self._slide_stop)
left_arrow.pack(side=tk.LEFT)
right_arrow = ttk.Label(slide_frame, text=" \u276f")
right_arrow.bind("<ButtonPress-1>", self._right_slide_start)
right_arrow.bind("<ButtonRelease-1>", 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()
+70 -9
View File
@@ -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('<<NotebookTabChanged>>', 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.