From c7030a45580c55e0b7301b62b1b03a57d6cb3343 Mon Sep 17 00:00:00 2001 From: Aivar Annamaa Date: Tue, 6 Aug 2024 00:35:46 +0300 Subject: [PATCH] Add disconnect button, #2328 --- thonny/backend.py | 17 +++++--- thonny/common.py | 1 + .../plugins/micropython/bare_metal_backend.py | 5 ++- thonny/plugins/micropython/mp_front.py | 6 +++ thonny/plugins/micropython/os_mp_backend.py | 4 +- .../micropython/subprocess_connection.py | 3 +- .../plugins/micropython/webrepl_connection.py | 4 +- thonny/running.py | 36 +++++++++++++++-- thonny/workbench.py | 40 +++++++++++++++++-- 9 files changed, 96 insertions(+), 20 deletions(-) diff --git a/thonny/backend.py b/thonny/backend.py index f7a834c61..e36a81e87 100644 --- a/thonny/backend.py +++ b/thonny/backend.py @@ -17,6 +17,7 @@ import thonny from thonny import report_time from thonny.common import ( # TODO: try to get rid of this + ALL_EXPLAINED_STATUS_CODE, IGNORED_FILES_AND_DIRS, PROCESS_ACK, BackendEvent, @@ -103,9 +104,15 @@ def handle_connection_error(self, error=None): message = "Connection lost" if error: message += " -- " + str(error) + self._send_output( + "\n", "stderr" + ) # in case we were at prompt or another line without newline self._send_output("\n" + message + "\n", "stderr") - self._send_output("\n" + "Use Stop/Restart to reconnect." + "\n", "stderr") - sys.exit(1) + self._send_output( + "\n" + "Click ☐ at the bottom of the window or use Stop/Restart to reconnect." + "\n", + "stderr", + ) + sys.exit(ALL_EXPLAINED_STATUS_CODE) def _current_command_is_interrupted(self): return getattr(self._current_command, "interrupted", False) @@ -300,7 +307,7 @@ def _handle_normal_command(self, cmd: CommandToBackend) -> None: except Exception as e: logger.exception("Exception while handling %r", cmd.name) self._report_internal_exception("Exception while handling %r" % cmd.name) - sys.exit(1) + sys.exit(ALL_EXPLAINED_STATUS_CODE) if response is False: # Command doesn't want to send any response @@ -785,7 +792,7 @@ def _try_load_paramiko(self): " Install it from 'Tools => Manage plug-ins' or via your system package manager.", file=sys.stderr, ) - sys.exit(1) + sys.exit(ALL_EXPLAINED_STATUS_CODE) def _connect(self): from paramiko import SSHException @@ -805,7 +812,7 @@ def _connect(self): print("Re-check your host, authentication method, password or keys.", file=sys.stderr) delete_stored_ssh_password() - sys.exit(1) + sys.exit(ALL_EXPLAINED_STATUS_CODE) def _create_remote_process(self, cmd_items: List[str], cwd: str, env: Dict) -> RemoteProcess: import shlex diff --git a/thonny/common.py b/thonny/common.py index 35d953831..5b19c545e 100644 --- a/thonny/common.py +++ b/thonny/common.py @@ -23,6 +23,7 @@ OBJECT_LINK_END = "[/object_link_for_thonny]" REMOTE_PATH_MARKER = " :: " PROCESS_ACK = "OK" +ALL_EXPLAINED_STATUS_CODE = 193 IGNORED_FILES_AND_DIRS = [ "System Volume Information", diff --git a/thonny/plugins/micropython/bare_metal_backend.py b/thonny/plugins/micropython/bare_metal_backend.py index adcf0bbca..1ec3fbcec 100644 --- a/thonny/plugins/micropython/bare_metal_backend.py +++ b/thonny/plugins/micropython/bare_metal_backend.py @@ -19,6 +19,7 @@ from thonny import report_time from thonny.backend import UploadDownloadMixin, convert_newlines_if_has_shebang from thonny.common import ( + ALL_EXPLAINED_STATUS_CODE, PROCESS_ACK, BackendEvent, EOFCommand, @@ -1742,7 +1743,7 @@ def launch_bare_metal_backend(backend_class: Callable[..., BareMetalMicroPythonB try: if args["port"] is None: print("\nPort not defined", file=sys.stderr) - sys.exit(1) + sys.exit(ALL_EXPLAINED_STATUS_CODE) elif args["port"] == "webrepl": connection = WebReplConnection(args["url"], args["password"]) else: @@ -1763,7 +1764,7 @@ def launch_bare_metal_backend(backend_class: Callable[..., BareMetalMicroPythonB msg = BackendEvent(event_type="ProgramOutput", stream_name="stderr", data=text) sys.stdout.write(serialize_message(msg) + "\n") sys.stdout.flush() - sys.exit(1) + sys.exit(ALL_EXPLAINED_STATUS_CODE) if __name__ == "__main__": diff --git a/thonny/plugins/micropython/mp_front.py b/thonny/plugins/micropython/mp_front.py index d59fb5ccf..9de4256dc 100644 --- a/thonny/plugins/micropython/mp_front.py +++ b/thonny/plugins/micropython/mp_front.py @@ -301,6 +301,9 @@ def _augment_dist_info(cls, dist_info: DistInfo) -> DistInfo: return dist_info + def needs_disconnect_button(self): + return True + class BareMetalMicroPythonProxy(MicroPythonProxy): def __init__(self, clean): @@ -505,6 +508,9 @@ def _show_error(self, text): def disconnect(self): self.destroy() + self._show_error( + "\nDisconnected.\n\nClick ☐ at the bottom of the window or use Stop/Restart to reconnect." + ) def get_node_label(self): if "CircuitPython" in self._welcome_text: diff --git a/thonny/plugins/micropython/os_mp_backend.py b/thonny/plugins/micropython/os_mp_backend.py index fef805e16..b1fdd0e70 100644 --- a/thonny/plugins/micropython/os_mp_backend.py +++ b/thonny/plugins/micropython/os_mp_backend.py @@ -18,7 +18,7 @@ import thonny from thonny import report_time from thonny.backend import SshMixin -from thonny.common import PROCESS_ACK, BackendEvent, serialize_message +from thonny.common import ALL_EXPLAINED_STATUS_CODE, PROCESS_ACK, BackendEvent, serialize_message from thonny.plugins.micropython.bare_metal_backend import LF, NORMAL_PROMPT from thonny.plugins.micropython.connection import MicroPythonConnection from thonny.plugins.micropython.mp_back import ( @@ -75,7 +75,7 @@ def __init__(self, args): msg = BackendEvent(event_type="ProgramOutput", stream_name="stderr", data=text) sys.stdout.write(serialize_message(msg) + "\n") sys.stdout.flush() - sys.exit(1) + sys.exit(ALL_EXPLAINED_STATUS_CODE) MicroPythonBackend.__init__(self, None, args) diff --git a/thonny/plugins/micropython/subprocess_connection.py b/thonny/plugins/micropython/subprocess_connection.py index 66cbbbba6..6251fc4d3 100644 --- a/thonny/plugins/micropython/subprocess_connection.py +++ b/thonny/plugins/micropython/subprocess_connection.py @@ -1,6 +1,7 @@ import signal import sys +from thonny.common import ALL_EXPLAINED_STATUS_CODE from thonny.plugins.micropython.connection import MicroPythonConnection @@ -15,7 +16,7 @@ def __init__(self, executable, args=[]): "ERROR: This back-end requires a Python package named 'ptyprocess'.\n" + "Install it via system package manager or 'Tools => Manage plug-ins'." ) - sys.exit(1) + sys.exit(ALL_EXPLAINED_STATUS_CODE) super().__init__() cmd = [executable] + args diff --git a/thonny/plugins/micropython/webrepl_connection.py b/thonny/plugins/micropython/webrepl_connection.py index 75ba3e944..2387f30e4 100644 --- a/thonny/plugins/micropython/webrepl_connection.py +++ b/thonny/plugins/micropython/webrepl_connection.py @@ -3,7 +3,7 @@ from logging import DEBUG, getLogger from queue import Queue -from ...common import execute_with_frontend_sys_path +from ...common import ALL_EXPLAINED_STATUS_CODE, execute_with_frontend_sys_path from .connection import MicroPythonConnection logger = getLogger(__name__) @@ -47,7 +47,7 @@ def _try_load_websockets(self): "Can't import `websockets`. You can install it via 'Tools => Manage plug-ins'.", file=sys.stderr, ) - sys.exit(1) + sys.exit(ALL_EXPLAINED_STATUS_CODE) def _wrap_ws_main(self): import asyncio diff --git a/thonny/running.py b/thonny/running.py index 4cb55affd..f05e5f935 100644 --- a/thonny/running.py +++ b/thonny/running.py @@ -46,6 +46,7 @@ report_time, ) from thonny.common import ( + ALL_EXPLAINED_STATUS_CODE, PROCESS_ACK, BackendEvent, CommandToBackend, @@ -657,7 +658,8 @@ def disconnect(self): proxy.disconnect() def disconnect_enabled(self): - return hasattr(self.get_backend_proxy(), "disconnect") + proxy = self.get_backend_proxy() + return proxy is not None and proxy.needs_disconnect_button() def ctrld(self): proxy = self.get_backend_proxy() @@ -749,8 +751,11 @@ def _pull_backend_messages(self): self._send_thread_commands() self._send_postponed_commands() - def _handle_backend_termination(self, returncode: int) -> None: - err = f"Process ended with exit code {returncode}." + def _handle_backend_termination(self, returncode: Optional[int]) -> None: + if returncode is None or returncode == ALL_EXPLAINED_STATUS_CODE: + err = "" + else: + err = f"Process ended with exit code {returncode}." try: faults_file = os.path.join(get_thonny_user_dir(), "backend_faults.log") @@ -896,6 +901,12 @@ def using_venv(self) -> bool: isinstance(self._proxy, (LocalCPythonProxy, SshCPythonProxy)) and self._proxy._in_venv ) + def is_connected(self): + if self._proxy is None: + return False + else: + return self._proxy.is_connected() + class BackendProxy(ABC): """Communicates with backend process. @@ -949,7 +960,24 @@ def destroy(self, for_restart: bool = False): ... @abstractmethod - def is_connected(self): ... + def is_connected(self) -> bool: + """Returns True if the backend is operational. + Returns False if the connection is lost (or not created yet) + or the remote process has died (or not created yet). + """ + ... + + def disconnect(self): + """ + Means different things for different backends. + For local CPython it does nothing (???). + For BareMetalMicroPython it means simply closing the serial connection. + For SSH back-ends it means closing the SSH connection. + """ + pass + + def needs_disconnect_button(self): + return False @abstractmethod def has_local_interpreter(self): ... diff --git a/thonny/workbench.py b/thonny/workbench.py index 0280993a5..276ef505f 100644 --- a/thonny/workbench.py +++ b/thonny/workbench.py @@ -223,6 +223,7 @@ def __init__(self, parsed_args: Dict[str, Any]) -> None: self.bind("", self._on_focus_out, True) self.bind("", self._on_focus_in, True) self.bind("BackendRestart", self._on_backend_restart, True) + self.bind("BackendTerminated", self._on_backend_terminated, True) self._publish_commands() self.initializing = False @@ -904,11 +905,38 @@ def _init_backend_switcher(self): menu_conf = get_style_configuration("Menu") self._backend_menu = tk.Menu(self._statusbar, tearoff=False, **menu_conf) - # Set up the button. - self._backend_button = CustomToolbutton(self._statusbar, text=get_menu_char()) + # Set up the buttons + self._connection_button = CustomToolbutton( + self._statusbar, text="", command=self._toggle_connection + ) + self._connection_button.grid(row=1, column=8, sticky="nes") + + self._backend_button = CustomToolbutton( + self._statusbar, text=get_menu_char(), command=self._post_backend_menu + ) - self._backend_button.grid(row=1, column=3, sticky="nes") - self._backend_button.configure(command=self._post_backend_menu) + self._backend_button.grid(row=1, column=9, sticky="nes") + + def _update_connection_button(self): + if get_runner().is_connected(): + # ▣☑☐🔲🔳☑️☹️🟢🔴🔵🟠🟡🟣🟤🟦🟧🟥🟨🟩🟪🟫🟬🟭🟮 + self._connection_button.configure(text=" ☑ ") + should_be_visible = get_runner().disconnect_enabled() + else: + self._connection_button.configure(text=" ☐ ") + should_be_visible = True + + if should_be_visible and not self._connection_button.winfo_ismapped(): + self._connection_button.grid() + elif not should_be_visible and self._connection_button.winfo_ismapped(): + self._connection_button.grid_remove() + + def _toggle_connection(self): + if get_runner().is_connected(): + get_runner().get_backend_proxy().disconnect() + else: + get_runner().restart_backend(clean=False, first=False, automatic=False) + self._update_connection_button() def _post_backend_menu(self): from thonny.plugins.micropython.uf2dialog import ( @@ -1016,6 +1044,10 @@ def _on_backend_restart(self, event): self._backend_conf_variable.set(value=switcher_value) self._last_active_backend_conf_variable_value = switcher_value self._backend_button.configure(text=desc + " " + get_menu_char()) + self._update_connection_button() + + def _on_backend_terminated(self, event): + self._update_connection_button() def _init_theming(self) -> None: if self.get_option("view.ui_theme") == "Kind of Aqua":