1# -*- coding: utf-8 -*-
2
3import ast
4import collections
5import importlib
6import logging
7import os.path
8import pkgutil
9import platform
10import queue
11import re
12import shutil
13import socket
14import sys
15import tkinter as tk
16import tkinter.font as tk_font
17import traceback
18from threading import Thread
19from tkinter import messagebox, ttk
20from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Type, Union, cast
21from warnings import warn
22
23import thonny
24from thonny import (
25    THONNY_USER_DIR,
26    assistance,
27    get_runner,
28    get_shell,
29    is_portable,
30    languages,
31    running,
32    ui_utils,
33)
34from thonny.common import Record, UserError, normpath_with_actual_case
35from thonny.config import try_load_configuration
36from thonny.config_ui import ConfigurationDialog
37from thonny.editors import EditorNotebook
38from thonny.languages import tr
39from thonny.misc_utils import (
40    copy_to_clipboard,
41    running_on_linux,
42    running_on_mac_os,
43    running_on_rpi,
44    running_on_windows,
45)
46from thonny.plugins.microbit import MicrobitFlashingDialog
47from thonny.plugins.micropython.uf2dialog import Uf2FlashingDialog
48from thonny.running import BackendProxy, Runner
49from thonny.shell import ShellView
50from thonny.ui_utils import (
51    AutomaticNotebook,
52    AutomaticPanedWindow,
53    create_tooltip,
54    get_style_configuration,
55    lookup_style_option,
56    register_latin_shortcut,
57    select_sequence,
58    sequence_to_accelerator,
59    caps_lock_is_on,
60    shift_is_pressed,
61    ems_to_pixels,
62)
63
64logger = logging.getLogger(__name__)
65
66SERVER_SUCCESS = "OK"
67SIMPLE_MODE_VIEWS = ["ShellView"]
68
69MenuItem = collections.namedtuple("MenuItem", ["group", "position_in_group", "tester"])
70BackendSpec = collections.namedtuple(
71    "BackendSpec", ["name", "proxy_class", "description", "config_page_constructor", "sort_key"]
72)
73
74BasicUiThemeSettings = Dict[str, Dict[str, Union[Dict, Sequence]]]
75CompoundUiThemeSettings = List[BasicUiThemeSettings]
76UiThemeSettings = Union[BasicUiThemeSettings, CompoundUiThemeSettings]
77FlexibleUiThemeSettings = Union[UiThemeSettings, Callable[[], UiThemeSettings]]
78
79SyntaxThemeSettings = Dict[str, Dict[str, Union[str, int, bool]]]
80FlexibleSyntaxThemeSettings = Union[SyntaxThemeSettings, Callable[[], SyntaxThemeSettings]]
81
82OBSOLETE_PLUGINS = [
83    "thonnycontrib.pi",
84    "thonnycontrib.micropython",
85    "thonnycontrib.circuitpython",
86    "thonnycontrib.microbit",
87    "thonnycontrib.esp",
88    "thonnycontrib.rpi_pico",
89]
90
91
92class Workbench(tk.Tk):
93    """
94    Thonny's main window and communication hub.
95
96    Is responsible for:
97
98        * creating the main window
99        * maintaining layout (_init_containers)
100        * loading plugins (_init_plugins, add_view, add_command)
101        * providing references to main components (editor_notebook and runner)
102        * communication between other components (see event_generate and bind)
103        * configuration services (get_option, set_option, add_defaults)
104        * loading translations
105        * maintaining fonts (named fonts, increasing and decreasing font size)
106
107    After workbench and plugins get loaded, 3 kinds of events start happening:
108
109        * User events (keypresses, mouse clicks, menu selections, ...)
110        * Virtual events (mostly via get_workbench().event_generate). These include:
111          events reported via and dispatched by Tk event system;
112          WorkbenchEvent-s, reported via and dispatched by enhanced get_workbench().event_generate.
113        * Events from the background process (program output notifications, input requests,
114          notifications about debugger's progress)
115
116    """
117
118    def __init__(self) -> None:
119        thonny._workbench = self
120        self.ready = False
121        self._closing = False
122        self._destroyed = False
123        self._lost_focus = False
124        self._is_portable = is_portable()
125        self.initializing = True
126
127        self._init_configuration()
128        self._tweak_environment()
129        self._check_init_server_loop()
130
131        tk.Tk.__init__(self, className="Thonny")
132        tk.Tk.report_callback_exception = self._on_tk_exception  # type: ignore
133        ui_utils.add_messagebox_parent_checker()
134        self._event_handlers = {}  # type: Dict[str, Set[Callable]]
135        self._images = (
136            set()
137        )  # type: Set[tk.PhotoImage] # keep images here to avoid Python garbage collecting them,
138        self._default_image_mapping = (
139            {}
140        )  # type: Dict[str, str] # to allow specify default alternative images
141        self._image_mapping_by_theme = (
142            {}
143        )  # type: Dict[str, Dict[str, str]] # theme-based alternative images
144        self._current_theme_name = "clam"  # will be overwritten later
145        self._backends = {}  # type: Dict[str, BackendSpec]
146        self._commands = []  # type: List[Dict[str, Any]]
147        self._toolbar_buttons = {}
148        self._view_records = {}  # type: Dict[str, Dict[str, Any]]
149        self.content_inspector_classes = []  # type: List[Type]
150        self._latin_shortcuts = {}  # type: Dict[Tuple[int,int], List[Tuple[Callable, Callable]]]
151
152        self._init_language()
153
154        self._active_ui_mode = os.environ.get("THONNY_MODE", self.get_option("general.ui_mode"))
155
156        self._init_scaling()
157
158        self._init_theming()
159        self._init_window()
160        self.option_add("*Dialog.msg.wrapLength", "8i")
161
162        self.add_view(
163            ShellView, tr("Shell"), "s", visible_by_default=True, default_position_key="A"
164        )
165
166        assistance.init()
167        self._runner = Runner()
168        self._init_hooks()  # Plugins may register hooks, so initialized them before to load plugins.
169        self._load_plugins()
170
171        self._editor_notebook = None  # type: Optional[EditorNotebook]
172        self._init_fonts()
173
174        self.reload_themes()
175        self._init_menu()
176
177        self._init_containers()
178        assert self._editor_notebook is not None
179
180        self._init_program_arguments_frame()
181        # self._init_backend_switcher()
182        self._init_regular_mode_link()  # TODO:
183
184        self._show_views()
185        # Make sure ShellView is loaded
186        get_shell()
187
188        self._init_commands()
189        self._init_icon()
190        try:
191            self._editor_notebook.load_startup_files()
192        except Exception:
193            self.report_exception()
194
195        self._editor_notebook.focus_set()
196        self._try_action(self._open_views)
197
198        self.bind_class("CodeViewText", "<<CursorMove>>", self.update_title, True)
199        self.bind_class("CodeViewText", "<<Modified>>", self.update_title, True)
200        self.bind_class("CodeViewText", "<<TextChange>>", self.update_title, True)
201        self.get_editor_notebook().bind("<<NotebookTabChanged>>", self.update_title, True)
202        self.get_editor_notebook().bind("<<NotebookTabChanged>>", self._update_toolbar, True)
203        self.bind_all("<KeyPress>", self._on_all_key_presses, True)
204        self.bind("<FocusOut>", self._on_focus_out, True)
205        self.bind("<FocusIn>", self._on_focus_in, True)
206        self.bind("BackendRestart", self._on_backend_restart, True)
207
208        self._publish_commands()
209        self.initializing = False
210        self.event_generate("<<WorkbenchInitialized>>")
211        self._make_sanity_checks()
212        if self._is_server():
213            self._poll_ipc_requests()
214
215        """
216        for name in sorted(sys.modules):
217            if (
218                not name.startswith("_")
219                and not name.startswith("thonny")
220                and not name.startswith("tkinter")
221            ):
222                print(name)
223        """
224
225        self.after(1, self._start_runner)  # Show UI already before waiting for the backend to start
226        self.after_idle(self.advertise_ready)
227
228    def advertise_ready(self):
229        self.event_generate("WorkbenchReady")
230        self.ready = True
231
232    def _make_sanity_checks(self):
233        home_dir = os.path.expanduser("~")
234        bad_home_msg = None
235        if home_dir == "~":
236            bad_home_msg = "Can not find your home directory."
237        elif not os.path.exists(home_dir):
238            bad_home_msg = "Reported home directory (%s) does not exist." % home_dir
239        if bad_home_msg:
240            messagebox.showwarning(
241                "Problems with home directory",
242                bad_home_msg + "\nThis may cause problems for Thonny.",
243                master=self,
244            )
245
246    def _try_action(self, action: Callable) -> None:
247        try:
248            action()
249        except Exception:
250            self.report_exception()
251
252    def _init_configuration(self) -> None:
253        self._configuration_manager = try_load_configuration(thonny.CONFIGURATION_FILE)
254        self._configuration_pages = []  # type: List[Tuple[str, str, Type[tk.Widget]]]
255
256        self.set_default("general.single_instance", thonny.SINGLE_INSTANCE_DEFAULT)
257        self.set_default("general.ui_mode", "simple" if running_on_rpi() else "regular")
258        self.set_default("general.debug_mode", False)
259        self.set_default("general.disable_notification_sound", False)
260        self.set_default("general.scaling", "default")
261        self.set_default("general.language", languages.BASE_LANGUAGE_CODE)
262        self.set_default("general.font_scaling_mode", "default")
263        self.set_default("general.environment", [])
264        self.set_default("file.avoid_zenity", False)
265        self.set_default("run.working_directory", os.path.expanduser("~"))
266        self.update_debug_mode()
267
268    def _tweak_environment(self):
269        for entry in self.get_option("general.environment"):
270            if "=" in entry:
271                key, val = entry.split("=", maxsplit=1)
272                os.environ[key] = os.path.expandvars(val)
273            else:
274                logger.warning("No '=' in environment entry '%s'", entry)
275
276    def update_debug_mode(self):
277        os.environ["THONNY_DEBUG"] = str(self.get_option("general.debug_mode", False))
278        thonny.set_logging_level()
279
280    def _init_language(self) -> None:
281        """Initialize language."""
282        languages.set_language(self.get_option("general.language"))
283
284    def _init_window(self) -> None:
285        self.title("Thonny")
286
287        self.set_default("layout.zoomed", False)
288        self.set_default("layout.top", 50)
289        self.set_default("layout.left", 150)
290        if self.in_simple_mode():
291            self.set_default("layout.width", 1050)
292            self.set_default("layout.height", 700)
293        else:
294            self.set_default("layout.width", 800)
295            self.set_default("layout.height", 650)
296        self.set_default("layout.w_width", 200)
297        self.set_default("layout.e_width", 200)
298        self.set_default("layout.s_height", 200)
299
300        # I don't actually need saved options for Full screen/maximize view,
301        # but it's easier to create menu items, if I use configuration manager's variables
302        self.set_default("view.full_screen", False)
303        self.set_default("view.maximize_view", False)
304
305        # In order to avoid confusion set these settings to False
306        # even if they were True when Thonny was last run
307        self.set_option("view.full_screen", False)
308        self.set_option("view.maximize_view", False)
309
310        self.geometry(
311            "{0}x{1}+{2}+{3}".format(
312                min(max(self.get_option("layout.width"), 320), self.winfo_screenwidth()),
313                min(max(self.get_option("layout.height"), 240), self.winfo_screenheight()),
314                min(max(self.get_option("layout.left"), 0), self.winfo_screenwidth() - 200),
315                min(max(self.get_option("layout.top"), 0), self.winfo_screenheight() - 200),
316            )
317        )
318
319        if self.get_option("layout.zoomed"):
320            ui_utils.set_zoomed(self, True)
321
322        self.protocol("WM_DELETE_WINDOW", self._on_close)
323        self.bind("<Configure>", self._on_configure, True)
324
325    def _init_statusbar(self):
326        self._statusbar = ttk.Frame(self)
327
328    def _init_icon(self) -> None:
329        # Window icons
330        if running_on_linux() and ui_utils.get_tk_version_info() >= (8, 6):
331            self.iconphoto(True, self.get_image("thonny.png"))
332        else:
333            icon_file = os.path.join(self.get_package_dir(), "res", "thonny.ico")
334            try:
335                self.iconbitmap(icon_file, default=icon_file)
336            except Exception:
337                try:
338                    # seems to work in mac
339                    self.iconbitmap(icon_file)
340                except Exception:
341                    pass
342
343    def _init_menu(self) -> None:
344        self.option_add("*tearOff", tk.FALSE)
345        if lookup_style_option("Menubar", "custom", False):
346            self._menubar = ui_utils.CustomMenubar(
347                self
348            )  # type: Union[tk.Menu, ui_utils.CustomMenubar]
349            if self.get_ui_mode() != "simple":
350                self._menubar.grid(row=0, sticky="nsew")
351        else:
352            opts = get_style_configuration("Menubar")
353            if "custom" in opts:
354                del opts["custom"]
355            self._menubar = tk.Menu(self, **opts)
356            if self.get_ui_mode() != "simple":
357                self["menu"] = self._menubar
358        self._menus = {}  # type: Dict[str, tk.Menu]
359        self._menu_item_specs = (
360            {}
361        )  # type: Dict[Tuple[str, str], MenuItem] # key is pair (menu_name, command_label)
362
363        # create standard menus in correct order
364        self.get_menu("file", tr("File"))
365        self.get_menu("edit", tr("Edit"))
366        self.get_menu("view", tr("View"))
367        self.get_menu("run", tr("Run"))
368        self.get_menu("tools", tr("Tools"))
369        self.get_menu("help", tr("Help"))
370
371    def _load_plugins(self) -> None:
372        # built-in plugins
373        import thonny.plugins  # pylint: disable=redefined-outer-name
374
375        self._load_plugins_from_path(thonny.plugins.__path__, "thonny.plugins.")  # type: ignore
376
377        # 3rd party plugins from namespace package
378        try:
379            import thonnycontrib  # @UnresolvedImport
380        except ImportError:
381            # No 3rd party plugins installed
382            pass
383        else:
384            self._load_plugins_from_path(thonnycontrib.__path__, "thonnycontrib.")
385
386    def _load_plugins_from_path(self, path: List[str], prefix: str) -> None:
387        load_function_name = "load_plugin"
388
389        modules = []
390        for _, module_name, _ in sorted(pkgutil.iter_modules(path, prefix), key=lambda x: x[2]):
391            if module_name in OBSOLETE_PLUGINS:
392                logging.debug("Skipping plug-in %s", module_name)
393            else:
394                try:
395                    m = importlib.import_module(module_name)
396                    if hasattr(m, load_function_name):
397                        modules.append(m)
398                except Exception:
399                    logging.exception("Failed loading plugin '" + module_name + "'")
400
401        def module_sort_key(m):
402            return getattr(m, "load_order_key", m.__name__)
403
404        for m in sorted(modules, key=module_sort_key):
405            getattr(m, load_function_name)()
406
407    def _init_fonts(self) -> None:
408        # set up editor and shell fonts
409        self.set_default("view.io_font_family", "Courier" if running_on_mac_os() else "Courier New")
410
411        default_editor_family = "Courier New"
412        families = tk_font.families()
413
414        for family in ["Consolas", "Ubuntu Mono", "Menlo", "DejaVu Sans Mono"]:
415            if family in families:
416                default_editor_family = family
417                break
418
419        self.set_default("view.editor_font_family", default_editor_family)
420
421        if running_on_mac_os():
422            self.set_default("view.editor_font_size", 14)
423            self.set_default("view.io_font_size", 12)
424        elif self.in_simple_mode():
425            self.set_default("view.editor_font_size", 12)
426            self.set_default("view.io_font_size", 12)
427        else:
428            self.set_default("view.editor_font_size", 13)
429            self.set_default("view.io_font_size", 11)
430
431        default_font = tk_font.nametofont("TkDefaultFont")
432
433        if running_on_linux():
434            heading_font = tk_font.nametofont("TkHeadingFont")
435            heading_font.configure(weight="normal")
436            caption_font = tk_font.nametofont("TkCaptionFont")
437            caption_font.configure(weight="normal", size=default_font.cget("size"))
438
439        small_link_ratio = 0.8 if running_on_windows() else 0.7
440        self._fonts = [
441            tk_font.Font(
442                name="SmallLinkFont",
443                family=default_font.cget("family"),
444                size=int(default_font.cget("size") * small_link_ratio),
445                underline=True,
446            ),
447            tk_font.Font(name="IOFont", family=self.get_option("view.io_font_family")),
448            tk_font.Font(
449                name="BoldIOFont", family=self.get_option("view.io_font_family"), weight="bold"
450            ),
451            tk_font.Font(
452                name="UnderlineIOFont",
453                family=self.get_option("view.io_font_family"),
454                underline=True,
455            ),
456            tk_font.Font(
457                name="ItalicIOFont", family=self.get_option("view.io_font_family"), slant="italic"
458            ),
459            tk_font.Font(
460                name="BoldItalicIOFont",
461                family=self.get_option("view.io_font_family"),
462                weight="bold",
463                slant="italic",
464            ),
465            tk_font.Font(name="EditorFont", family=self.get_option("view.editor_font_family")),
466            tk_font.Font(name="SmallEditorFont", family=self.get_option("view.editor_font_family")),
467            tk_font.Font(
468                name="BoldEditorFont",
469                family=self.get_option("view.editor_font_family"),
470                weight="bold",
471            ),
472            tk_font.Font(
473                name="ItalicEditorFont",
474                family=self.get_option("view.editor_font_family"),
475                slant="italic",
476            ),
477            tk_font.Font(
478                name="BoldItalicEditorFont",
479                family=self.get_option("view.editor_font_family"),
480                weight="bold",
481                slant="italic",
482            ),
483            tk_font.Font(
484                name="TreeviewFont",
485                family=default_font.cget("family"),
486                size=default_font.cget("size"),
487            ),
488            tk_font.Font(
489                name="BoldTkDefaultFont",
490                family=default_font.cget("family"),
491                size=default_font.cget("size"),
492                weight="bold",
493            ),
494            tk_font.Font(
495                name="ItalicTkDefaultFont",
496                family=default_font.cget("family"),
497                size=default_font.cget("size"),
498                slant="italic",
499            ),
500            tk_font.Font(
501                name="UnderlineTkDefaultFont",
502                family=default_font.cget("family"),
503                size=default_font.cget("size"),
504                underline=1,
505            ),
506        ]
507
508        self.update_fonts()
509
510    def _start_runner(self) -> None:
511        try:
512            self.update_idletasks()  # allow UI to complete
513            thonny._runner = self._runner
514            self._runner.start()
515            self._update_toolbar()
516        except Exception:
517            self.report_exception("Error when initializing backend")
518
519    def _check_init_server_loop(self) -> None:
520        """Socket will listen requests from newer Thonny instances,
521        which try to delegate opening files to older instance"""
522
523        if not self.get_option("general.single_instance") or os.path.exists(
524            thonny.get_ipc_file_path()
525        ):
526            self._ipc_requests = None
527            return
528
529        self._ipc_requests = queue.Queue()  # type: queue.Queue[bytes]
530        server_socket, actual_secret = self._create_server_socket()
531        server_socket.listen(10)
532
533        def server_loop():
534            while True:
535                logging.debug("Waiting for next client")
536                (client_socket, _) = server_socket.accept()
537                try:
538                    data = bytes()
539                    while True:
540                        new_data = client_socket.recv(1024)
541                        if len(new_data) > 0:
542                            data += new_data
543                        else:
544                            break
545                    proposed_secret, args = ast.literal_eval(data.decode("UTF-8"))
546                    if proposed_secret == actual_secret:
547                        self._ipc_requests.put(args)
548                        # respond OK
549                        client_socket.sendall(SERVER_SUCCESS.encode(encoding="utf-8"))
550                        client_socket.shutdown(socket.SHUT_WR)
551                        logging.debug("AFTER NEW REQUEST %s", client_socket)
552                    else:
553                        client_socket.shutdown(socket.SHUT_WR)
554                        raise PermissionError("Wrong secret")
555
556                except Exception as e:
557                    logger.exception("Error in ipc server loop", exc_info=e)
558
559        Thread(target=server_loop, daemon=True).start()
560
561    def _create_server_socket(self):
562        if running_on_windows():
563            server_socket = socket.socket(socket.AF_INET)  # @UndefinedVariable
564            server_socket.bind(("127.0.0.1", 0))
565
566            # advertise the port and secret
567            port = server_socket.getsockname()[1]
568            import uuid
569
570            secret = str(uuid.uuid4())
571
572            with open(thonny.get_ipc_file_path(), "w") as fp:
573                fp.write(str(port) + "\n")
574                fp.write(secret + "\n")
575
576        else:
577            server_socket = socket.socket(socket.AF_UNIX)  # @UndefinedVariable
578            server_socket.bind(thonny.get_ipc_file_path())
579            secret = ""
580
581        os.chmod(thonny.get_ipc_file_path(), 0o600)
582        return server_socket, secret
583
584    def _init_commands(self) -> None:
585
586        self.add_command(
587            "exit",
588            "file",
589            tr("Exit"),
590            self._on_close,
591            default_sequence=select_sequence("<Alt-F4>", "<Command-q>", "<Control-q>"),
592            extra_sequences=["<Alt-F4>"]
593            if running_on_linux()
594            else ["<Control-q>"]
595            if running_on_windows()
596            else [],
597        )
598
599        self.add_command("show_options", "tools", tr("Options..."), self.show_options, group=180)
600        self.createcommand("::tk::mac::ShowPreferences", self.show_options)
601        self.createcommand("::tk::mac::Quit", self._mac_quit)
602
603        self.add_command(
604            "increase_font_size",
605            "view",
606            tr("Increase font size"),
607            lambda: self._change_font_size(1),
608            default_sequence=select_sequence("<Control-plus>", "<Command-Shift-plus>"),
609            extra_sequences=["<Control-KP_Add>"],
610            group=60,
611        )
612
613        self.add_command(
614            "decrease_font_size",
615            "view",
616            tr("Decrease font size"),
617            lambda: self._change_font_size(-1),
618            default_sequence=select_sequence("<Control-minus>", "<Command-minus>"),
619            extra_sequences=["<Control-KP_Subtract>"],
620            group=60,
621        )
622
623        self.bind("<Control-MouseWheel>", self._cmd_zoom_with_mouse, True)
624
625        self.add_command(
626            "focus_editor",
627            "view",
628            tr("Focus editor"),
629            self._cmd_focus_editor,
630            default_sequence=select_sequence("<Alt-e>", "<Command-Alt-e>"),
631            group=70,
632        )
633
634        self.add_command(
635            "focus_shell",
636            "view",
637            tr("Focus shell"),
638            self._cmd_focus_shell,
639            default_sequence=select_sequence("<Alt-s>", "<Command-Alt-s>"),
640            group=70,
641        )
642
643        if self.get_ui_mode() == "expert":
644
645            self.add_command(
646                "toggle_maximize_view",
647                "view",
648                tr("Maximize view"),
649                self._cmd_toggle_maximize_view,
650                flag_name="view.maximize_view",
651                default_sequence=None,
652                group=80,
653            )
654            self.bind_class("TNotebook", "<Double-Button-1>", self._maximize_view, True)
655            self.bind("<Escape>", self._unmaximize_view, True)
656
657            self.add_command(
658                "toggle_maximize_view",
659                "view",
660                tr("Full screen"),
661                self._cmd_toggle_full_screen,
662                flag_name="view.full_screen",
663                default_sequence=select_sequence("<F11>", "<Command-Shift-F>"),
664                group=80,
665            )
666
667        if self.in_simple_mode():
668            self.add_command(
669                "font",
670                "tools",
671                tr("Change font size"),
672                caption=tr("Zoom"),
673                handler=self._toggle_font_size,
674                image="zoom",
675                include_in_toolbar=True,
676            )
677
678            self.add_command(
679                "quit",
680                "help",
681                tr("Exit Thonny"),
682                self._on_close,
683                image="quit",
684                caption=tr("Quit"),
685                include_in_toolbar=True,
686                group=101,
687            )
688
689        if thonny.in_debug_mode():
690            self.bind_all("<Control-Shift-Alt-D>", self._print_state_for_debugging, True)
691
692    def _print_state_for_debugging(self, event) -> None:
693        print(get_runner()._postponed_commands)
694
695    def _init_containers(self) -> None:
696
697        margin = 10
698        # Main frame functions as
699        # - a background behind padding of main_pw, without this OS X leaves white border
700        # - a container to be hidden, when a view is maximized and restored when view is back home
701        main_frame = ttk.Frame(self)  #
702        self._main_frame = main_frame
703        main_frame.grid(row=1, column=0, sticky=tk.NSEW)
704        self.columnconfigure(0, weight=1)
705        self.rowconfigure(1, weight=1)
706        self._maximized_view = None  # type: Optional[tk.Widget]
707
708        self._toolbar = ttk.Frame(main_frame, padding=0)
709        self._toolbar.grid(column=0, row=0, sticky=tk.NSEW, padx=margin, pady=(5, 0))
710
711        self.set_default("layout.west_pw_width", self.scale(150))
712        self.set_default("layout.east_pw_width", self.scale(150))
713
714        self.set_default("layout.s_nb_height", self.scale(150))
715        self.set_default("layout.nw_nb_height", self.scale(150))
716        self.set_default("layout.sw_nb_height", self.scale(150))
717        self.set_default("layout.ne_nb_height", self.scale(150))
718        self.set_default("layout.se_nb_height", self.scale(150))
719
720        self._main_pw = AutomaticPanedWindow(main_frame, orient=tk.HORIZONTAL)
721
722        self._main_pw.grid(column=0, row=1, sticky=tk.NSEW, padx=margin, pady=(margin, 0))
723        main_frame.columnconfigure(0, weight=1)
724        main_frame.rowconfigure(1, weight=1)
725
726        self._west_pw = AutomaticPanedWindow(
727            self._main_pw,
728            1,
729            orient=tk.VERTICAL,
730            preferred_size_in_pw=self.get_option("layout.west_pw_width"),
731        )
732        self._center_pw = AutomaticPanedWindow(self._main_pw, 2, orient=tk.VERTICAL)
733        self._east_pw = AutomaticPanedWindow(
734            self._main_pw,
735            3,
736            orient=tk.VERTICAL,
737            preferred_size_in_pw=self.get_option("layout.east_pw_width"),
738        )
739
740        self._view_notebooks = {
741            "nw": AutomaticNotebook(
742                self._west_pw, 1, preferred_size_in_pw=self.get_option("layout.nw_nb_height")
743            ),
744            "w": AutomaticNotebook(self._west_pw, 2),
745            "sw": AutomaticNotebook(
746                self._west_pw, 3, preferred_size_in_pw=self.get_option("layout.sw_nb_height")
747            ),
748            "s": AutomaticNotebook(
749                self._center_pw, 3, preferred_size_in_pw=self.get_option("layout.s_nb_height")
750            ),
751            "ne": AutomaticNotebook(
752                self._east_pw, 1, preferred_size_in_pw=self.get_option("layout.ne_nb_height")
753            ),
754            "e": AutomaticNotebook(self._east_pw, 2),
755            "se": AutomaticNotebook(
756                self._east_pw, 3, preferred_size_in_pw=self.get_option("layout.se_nb_height")
757            ),
758        }
759
760        for nb_name in self._view_notebooks:
761            self.set_default("layout.notebook_" + nb_name + "_visible_view", None)
762
763        self._editor_notebook = EditorNotebook(self._center_pw)
764        self._editor_notebook.position_key = 1
765        self._center_pw.insert("auto", self._editor_notebook)
766
767        self._statusbar = ttk.Frame(main_frame)
768        self._statusbar.grid(column=0, row=2, sticky="nsew", padx=margin, pady=(0))
769        self._statusbar.columnconfigure(2, weight=2)
770        self._status_label = ttk.Label(self._statusbar, text="")
771        self._status_label.grid(row=1, column=1, sticky="w")
772
773        self._init_backend_switcher()
774
775    def _init_backend_switcher(self):
776
777        # Set up the menu
778        self._backend_conf_variable = tk.StringVar(value="{}")
779
780        if running_on_mac_os():
781            menu_conf = {}
782        else:
783            menu_conf = get_style_configuration("Menu")
784        self._backend_menu = tk.Menu(self._statusbar, tearoff=False, **menu_conf)
785
786        # Set up the button
787        self._backend_button = ttk.Button(self._statusbar, text="", style="Toolbutton")
788
789        self._backend_button.grid(row=1, column=3, sticky="e")
790        self._backend_button.configure(command=self._post_backend_menu)
791
792    def _post_backend_menu(self):
793        menu_font = tk_font.nametofont("TkMenuFont")
794
795        def choose_backend():
796            backend_conf = ast.literal_eval(self._backend_conf_variable.get())
797            assert isinstance(backend_conf, dict), "backend conf is %r" % backend_conf
798            for name, value in backend_conf.items():
799                self.set_option(name, value)
800            get_runner().restart_backend(False)
801
802        self._backend_menu.delete(0, "end")
803        max_description_width = 0
804        button_text_width = menu_font.measure(self._backend_button.cget("text"))
805
806        num_entries = 0
807        for backend in sorted(self.get_backends().values(), key=lambda x: x.sort_key):
808            entries = backend.proxy_class.get_switcher_entries()
809
810            if not entries:
811                continue
812
813            if len(entries) == 1:
814                self._backend_menu.add_radiobutton(
815                    label=backend.description,
816                    command=choose_backend,
817                    variable=self._backend_conf_variable,
818                    value=repr(entries[0][0]),
819                )
820            else:
821                submenu = tk.Menu(self._backend_menu, tearoff=False)
822                for conf, label in entries:
823                    submenu.add_radiobutton(
824                        label=label,
825                        command=choose_backend,
826                        variable=self._backend_conf_variable,
827                        value=repr(conf),
828                    )
829                self._backend_menu.add_cascade(label=backend.description, menu=submenu)
830
831            max_description_width = max(
832                menu_font.measure(backend.description), max_description_width
833            )
834        num_entries += 1
835
836        # self._backend_conf_variable.set(value=self.get_option("run.backend_name"))
837
838        self._backend_menu.add_separator()
839        self._backend_menu.add_command(
840            label=tr("Configure interpreter..."),
841            command=lambda: self.show_options("interpreter"),
842        )
843
844        post_x = self._backend_button.winfo_rootx()
845        post_y = (
846            self._backend_button.winfo_rooty()
847            - self._backend_menu.yposition("end")
848            - self._backend_menu.yposition(1)
849        )
850
851        if self.winfo_screenwidth() / self.winfo_screenheight() > 2:
852            # Most likely several monitors.
853            # Tk will adjust x properly with single monitor, but when Thonny is maximized
854            # on a monitor, which has another monitor to its right, the menu can be partially
855            # displayed on another monitor (at least in Ubuntu).
856            width_diff = max_description_width - button_text_width
857            post_x -= width_diff + menu_font.measure("mmm")
858
859        try:
860            self._backend_menu.tk_popup(post_x, post_y)
861        except tk.TclError as e:
862            if not 'unknown option "-state"' in str(e):
863                logger.warning("Problem with switcher popup", exc_info=e)
864
865    def _on_backend_restart(self, event):
866        proxy = get_runner().get_backend_proxy()
867        if proxy:
868            desc = proxy.get_clean_description()
869            self._backend_conf_variable.set(value=repr(proxy.get_current_switcher_configuration()))
870        else:
871            backend_conf = self._backends.get(self.get_option("run.backend_name"), None)
872            if backend_conf:
873                desc = backend_conf.description
874            else:
875                desc = "<no backend>"
876        self._backend_button.configure(text=desc)
877
878    def _init_theming(self) -> None:
879        self._style = ttk.Style()
880        self._ui_themes = (
881            {}
882        )  # type: Dict[str, Tuple[Optional[str], FlexibleUiThemeSettings, Dict[str, str]]] # value is (parent, settings, images)
883        self._syntax_themes = (
884            {}
885        )  # type: Dict[str, Tuple[Optional[str], FlexibleSyntaxThemeSettings]] # value is (parent, settings)
886        self.set_default("view.ui_theme", ui_utils.get_default_theme())
887
888    def add_command(
889        self,
890        command_id: str,
891        menu_name: str,
892        command_label: str,
893        handler: Optional[Callable[[], None]] = None,
894        tester: Optional[Callable[[], bool]] = None,
895        default_sequence: Optional[str] = None,
896        extra_sequences: Sequence[str] = [],
897        flag_name: Optional[str] = None,
898        skip_sequence_binding: bool = False,
899        accelerator: Optional[str] = None,
900        group: int = 99,
901        position_in_group="end",
902        image: Optional[str] = None,
903        caption: Optional[str] = None,
904        alternative_caption: Optional[str] = None,
905        include_in_menu: bool = True,
906        include_in_toolbar: bool = False,
907        submenu: Optional[tk.Menu] = None,
908        bell_when_denied: bool = True,
909        show_extra_sequences=False,
910    ) -> None:
911        """Registers an item to be shown in specified menu.
912
913        Args:
914            menu_name: Name of the menu the command should appear in.
915                Standard menu names are "file", "edit", "run", "view", "help".
916                If a menu with given name doesn't exist, then new menu is created
917                (with label=name).
918            command_label: Label for this command
919            handler: Function to be called when the command is invoked.
920                Should be callable with one argument (the event or None).
921            tester: Function to be called for determining if command is available or not.
922                Should be callable with one argument (the event or None).
923                Should return True or False.
924                If None then command is assumed to be always available.
925            default_sequence: Default shortcut (Tk style)
926            flag_name: Used for toggle commands. Indicates the name of the boolean option.
927            group: Used for grouping related commands together. Value should be int.
928                Groups with smaller numbers appear before.
929
930        Returns:
931            None
932        """
933
934        # Temporary solution for plug-ins made for versions before 3.2
935        if menu_name == "device":
936            menu_name = "tools"
937            group = 150
938
939        # store command to be published later
940        self._commands.append(
941            dict(
942                command_id=command_id,
943                menu_name=menu_name,
944                command_label=command_label,
945                handler=handler,
946                tester=tester,
947                default_sequence=default_sequence,
948                extra_sequences=extra_sequences,
949                flag_name=flag_name,
950                skip_sequence_binding=skip_sequence_binding,
951                accelerator=accelerator,
952                group=group,
953                position_in_group=position_in_group,
954                image=image,
955                caption=caption,
956                alternative_caption=alternative_caption,
957                include_in_menu=include_in_menu,
958                include_in_toolbar=include_in_toolbar,
959                submenu=submenu,
960                bell_when_denied=bell_when_denied,
961                show_extra_sequences=show_extra_sequences,
962            )
963        )
964
965    def _publish_commands(self) -> None:
966        for cmd in self._commands:
967            self._publish_command(**cmd)
968
969    def _publish_command(
970        self,
971        command_id: str,
972        menu_name: str,
973        command_label: str,
974        handler: Optional[Callable[[], None]],
975        tester: Optional[Callable[[], bool]] = None,
976        default_sequence: Optional[str] = None,
977        extra_sequences: Sequence[str] = [],
978        flag_name: Optional[str] = None,
979        skip_sequence_binding: bool = False,
980        accelerator: Optional[str] = None,
981        group: int = 99,
982        position_in_group="end",
983        image: Optional[str] = None,
984        caption: Optional[str] = None,
985        alternative_caption: Optional[str] = None,
986        include_in_menu: bool = True,
987        include_in_toolbar: bool = False,
988        submenu: Optional[tk.Menu] = None,
989        bell_when_denied: bool = True,
990        show_extra_sequences: bool = False,
991    ) -> None:
992        def dispatch(event=None):
993            if not tester or tester():
994                denied = False
995                handler()
996            else:
997                denied = True
998                logging.debug("Command '" + command_id + "' execution denied")
999                if bell_when_denied:
1000                    self.bell()
1001
1002            self.event_generate("UICommandDispatched", command_id=command_id, denied=denied)
1003
1004        def dispatch_if_caps_lock_is_on(event):
1005            if caps_lock_is_on(event.state) and not shift_is_pressed(event.state):
1006                dispatch(event)
1007
1008        sequence_option_name = "shortcuts." + command_id
1009        self.set_default(sequence_option_name, default_sequence)
1010        sequence = self.get_option(sequence_option_name)
1011
1012        if sequence:
1013            if not skip_sequence_binding:
1014                self.bind_all(sequence, dispatch, True)
1015                # work around caps-lock problem
1016                # https://github.com/thonny/thonny/issues/1347
1017                # Unfortunately the solution doesn't work with sequences involving Shift
1018                # (in Linux with the expected solution Shift sequences did not come through
1019                # with Caps Lock, and in Windows, the shift handlers started to react
1020                # on non-shift keypresses)
1021                # Python 3.7 on Mac seems to require lower letters for shift sequences.
1022                parts = sequence.strip("<>").split("-")
1023                if len(parts[-1]) == 1 and parts[-1].islower() and "Shift" not in parts:
1024                    lock_sequence = "<%s-Lock-%s>" % ("-".join(parts[:-1]), parts[-1].upper())
1025                    self.bind_all(lock_sequence, dispatch_if_caps_lock_is_on, True)
1026
1027            # register shortcut even without binding
1028            register_latin_shortcut(self._latin_shortcuts, sequence, handler, tester)
1029
1030        for extra_sequence in extra_sequences:
1031            self.bind_all(extra_sequence, dispatch, True)
1032            if "greek_" not in extra_sequence.lower() or running_on_linux():
1033                # Use greek alternatives only on Linux
1034                # (they are not required on Mac
1035                # and cause double events on Windows)
1036                register_latin_shortcut(self._latin_shortcuts, sequence, handler, tester)
1037
1038        menu = self.get_menu(menu_name)
1039
1040        if image:
1041            _image = self.get_image(image)  # type: Optional[tk.PhotoImage]
1042            _disabled_image = self.get_image(image, disabled=True)
1043        else:
1044            _image = None
1045            _disabled_image = None
1046
1047        if not accelerator and sequence:
1048            accelerator = sequence_to_accelerator(sequence)
1049            """
1050            # Does not work on Mac
1051            if show_extra_sequences:
1052                for extra_seq in extra_sequences:
1053                    accelerator += " or " + sequence_to_accelerator(extra_seq)
1054            """
1055
1056        if include_in_menu:
1057
1058            def dispatch_from_menu():
1059                # I don't like that Tk menu toggles checbutton variable
1060                # automatically before calling the handler.
1061                # So I revert the toggle before calling the actual handler.
1062                # This way the handler doesn't have to worry whether it
1063                # needs to toggle the variable or not, and it can choose to
1064                # decline the toggle.
1065                if flag_name is not None:
1066                    var = self.get_variable(flag_name)
1067                    var.set(not var.get())
1068
1069                dispatch(None)
1070
1071            if _image and lookup_style_option("OPTIONS", "icons_in_menus", True):
1072                menu_image = _image  # type: Optional[tk.PhotoImage]
1073            elif flag_name:
1074                # no image or black next to a checkbox
1075                menu_image = None
1076            else:
1077                menu_image = self.get_image("16x16-blank")
1078
1079            # remember the details that can't be stored in Tkinter objects
1080            self._menu_item_specs[(menu_name, command_label)] = MenuItem(
1081                group, position_in_group, tester
1082            )
1083
1084            menu.insert(
1085                self._find_location_for_menu_item(menu_name, command_label),
1086                "checkbutton" if flag_name else "cascade" if submenu else "command",
1087                label=command_label,
1088                accelerator=accelerator,
1089                image=menu_image,
1090                compound=tk.LEFT,
1091                variable=self.get_variable(flag_name) if flag_name else None,
1092                command=dispatch_from_menu if handler else None,
1093                menu=submenu,
1094            )
1095
1096        if include_in_toolbar:
1097            toolbar_group = self._get_menu_index(menu) * 100 + group
1098            assert caption is not None
1099            self._add_toolbar_button(
1100                command_id,
1101                _image,
1102                _disabled_image,
1103                command_label,
1104                caption,
1105                caption if alternative_caption is None else alternative_caption,
1106                accelerator,
1107                handler,
1108                tester,
1109                toolbar_group,
1110            )
1111
1112    def add_view(
1113        self,
1114        cls: Type[tk.Widget],
1115        label: str,
1116        default_location: str,
1117        visible_by_default: bool = False,
1118        default_position_key: Optional[str] = None,
1119    ) -> None:
1120        """Adds item to "View" menu for showing/hiding given view.
1121
1122        Args:
1123            view_class: Class or constructor for view. Should be callable with single
1124                argument (the master of the view)
1125            label: Label of the view tab
1126            location: Location descriptor. Can be "nw", "sw", "s", "se", "ne"
1127
1128        Returns: None
1129        """
1130        view_id = cls.__name__
1131        if default_position_key == None:
1132            default_position_key = label
1133
1134        self.set_default("view." + view_id + ".visible", visible_by_default)
1135        self.set_default("view." + view_id + ".location", default_location)
1136        self.set_default("view." + view_id + ".position_key", default_position_key)
1137
1138        if self.in_simple_mode():
1139            visibility_flag = tk.BooleanVar(value=view_id in SIMPLE_MODE_VIEWS)
1140        else:
1141            visibility_flag = cast(tk.BooleanVar, self.get_variable("view." + view_id + ".visible"))
1142
1143        self._view_records[view_id] = {
1144            "class": cls,
1145            "label": label,
1146            "location": self.get_option("view." + view_id + ".location"),
1147            "position_key": self.get_option("view." + view_id + ".position_key"),
1148            "visibility_flag": visibility_flag,
1149        }
1150
1151        # handler
1152        def toggle_view_visibility():
1153            if visibility_flag.get():
1154                self.hide_view(view_id)
1155            else:
1156                self.show_view(view_id, True)
1157
1158        self.add_command(
1159            "toggle_" + view_id,
1160            menu_name="view",
1161            command_label=label,
1162            handler=toggle_view_visibility,
1163            flag_name="view." + view_id + ".visible",
1164            group=10,
1165            position_in_group="alphabetic",
1166        )
1167
1168    def add_configuration_page(
1169        self, key: str, title: str, page_class: Type[tk.Widget], order: int
1170    ) -> None:
1171        self._configuration_pages.append((key, title, page_class, order))
1172
1173    def add_content_inspector(self, inspector_class: Type) -> None:
1174        self.content_inspector_classes.append(inspector_class)
1175
1176    def add_backend(
1177        self,
1178        name: str,
1179        proxy_class: Type[BackendProxy],
1180        description: str,
1181        config_page_constructor,
1182        sort_key=None,
1183    ) -> None:
1184        self._backends[name] = BackendSpec(
1185            name,
1186            proxy_class,
1187            description,
1188            config_page_constructor,
1189            sort_key if sort_key is not None else description,
1190        )
1191
1192        # assing names to related classes
1193        proxy_class.backend_name = name  # type: ignore
1194        proxy_class.backend_description = description  # type: ignore
1195        if not getattr(config_page_constructor, "backend_name", None):
1196            config_page_constructor.backend_name = name
1197
1198    def add_ui_theme(
1199        self,
1200        name: str,
1201        parent: Union[str, None],
1202        settings: FlexibleUiThemeSettings,
1203        images: Dict[str, str] = {},
1204    ) -> None:
1205        if name in self._ui_themes:
1206            warn(tr("Overwriting theme '%s'") % name)
1207
1208        self._ui_themes[name] = (parent, settings, images)
1209
1210    def add_syntax_theme(
1211        self, name: str, parent: Optional[str], settings: FlexibleSyntaxThemeSettings
1212    ) -> None:
1213        if name in self._syntax_themes:
1214            warn(tr("Overwriting theme '%s'") % name)
1215
1216        self._syntax_themes[name] = (parent, settings)
1217
1218    def get_usable_ui_theme_names(self) -> Sequence[str]:
1219        return sorted([name for name in self._ui_themes if self._ui_themes[name][0] is not None])
1220
1221    def get_syntax_theme_names(self) -> Sequence[str]:
1222        return sorted(self._syntax_themes.keys())
1223
1224    def get_ui_mode(self) -> str:
1225        return self._active_ui_mode
1226
1227    def in_simple_mode(self) -> bool:
1228        return self.get_ui_mode() == "simple"
1229
1230    def scale(self, value: Union[int, float]) -> int:
1231        if isinstance(value, (int, float)):
1232            # using int instead of round so that thin lines will stay
1233            # one pixel even with scaling_factor 1.67
1234            result = int(self._scaling_factor * value)
1235            if result == 0 and value > 0:
1236                # don't lose thin lines because of scaling
1237                return 1
1238            else:
1239                return result
1240        else:
1241            raise NotImplementedError("Only numeric dimensions supported at the moment")
1242
1243    def _register_ui_theme_as_tk_theme(self, name: str) -> None:
1244        # collect settings from all ancestors
1245        total_settings = []  # type: List[FlexibleUiThemeSettings]
1246        total_images = {}  # type: Dict[str, str]
1247        temp_name = name
1248        while True:
1249            parent, settings, images = self._ui_themes[temp_name]
1250            total_settings.insert(0, settings)
1251            for img_name in images:
1252                total_images.setdefault(img_name, images[img_name])
1253
1254            if parent is not None:
1255                temp_name = parent
1256            else:
1257                # reached start of the chain
1258                break
1259
1260        assert temp_name in self._style.theme_names()
1261        # only root of the ancestors is relevant for theme_create,
1262        # because the method actually doesn't take parent settings into account
1263        # (https://mail.python.org/pipermail/tkinter-discuss/2015-August/003752.html)
1264        self._style.theme_create(name, temp_name)
1265        self._image_mapping_by_theme[name] = total_images
1266
1267        # load images
1268        self.get_image("tab-close", "img_close")
1269        self.get_image("tab-close-active", "img_close_active")
1270
1271        # apply settings starting from root ancestor
1272        for settings in total_settings:
1273            if callable(settings):
1274                settings = settings()
1275
1276            if isinstance(settings, dict):
1277                self._style.theme_settings(name, settings)
1278            else:
1279                for subsettings in settings:
1280                    self._style.theme_settings(name, subsettings)
1281
1282    def _apply_ui_theme(self, name: str) -> None:
1283        self._current_theme_name = name
1284        if name not in self._style.theme_names():
1285            self._register_ui_theme_as_tk_theme(name)
1286
1287        self._style.theme_use(name)
1288
1289        # https://wiki.tcl.tk/37973#pagetocfe8b22ab
1290        for setting in ["background", "foreground", "selectBackground", "selectForeground"]:
1291            value = self._style.lookup("Listbox", setting)
1292            if value:
1293                self.option_add("*TCombobox*Listbox." + setting, value)
1294                self.option_add("*Listbox." + setting, value)
1295
1296        text_opts = self._style.configure("Text")
1297        if text_opts:
1298            for key in text_opts:
1299                self.option_add("*Text." + key, text_opts[key])
1300
1301        if hasattr(self, "_menus"):
1302            # if menus have been initialized, ie. when theme is being changed
1303            for menu in self._menus.values():
1304                menu.configure(get_style_configuration("Menu"))
1305
1306        self.update_fonts()
1307
1308    def _apply_syntax_theme(self, name: str) -> None:
1309        def get_settings(name):
1310            try:
1311                parent, settings = self._syntax_themes[name]
1312            except KeyError:
1313                self.report_exception("Can't find theme '%s'" % name)
1314                return {}
1315
1316            if callable(settings):
1317                settings = settings()
1318
1319            if parent is None:
1320                return settings
1321            else:
1322                result = get_settings(parent)
1323                for key in settings:
1324                    if key in result:
1325                        result[key].update(settings[key])
1326                    else:
1327                        result[key] = settings[key]
1328                return result
1329
1330        from thonny import codeview
1331
1332        codeview.set_syntax_options(get_settings(name))
1333
1334    def reload_themes(self) -> None:
1335        preferred_theme = self.get_option("view.ui_theme")
1336        available_themes = self.get_usable_ui_theme_names()
1337
1338        if preferred_theme in available_themes:
1339            self._apply_ui_theme(preferred_theme)
1340        elif "Enhanced Clam" in available_themes:
1341            self._apply_ui_theme("Enhanced Clam")
1342        elif "Windows" in available_themes:
1343            self._apply_ui_theme("Windows")
1344
1345        self._apply_syntax_theme(self.get_option("view.syntax_theme"))
1346
1347    def uses_dark_ui_theme(self) -> bool:
1348
1349        name = self._style.theme_use()
1350        while True:
1351            if "dark" in name.lower():
1352                return True
1353
1354            name, _, _ = self._ui_themes[name]
1355            if name is None:
1356                # reached start of the chain
1357                break
1358
1359        return False
1360
1361    def _init_program_arguments_frame(self) -> None:
1362        self.set_default("view.show_program_arguments", False)
1363        self.set_default("run.program_arguments", "")
1364        self.set_default("run.past_program_arguments", [])
1365
1366        visibility_var = self.get_variable("view.show_program_arguments")
1367        content_var = self.get_variable("run.program_arguments")
1368
1369        frame = ttk.Frame(self._toolbar)
1370        col = 1000
1371        self._toolbar.columnconfigure(col, weight=1)
1372
1373        label = ttk.Label(frame, text=tr("Program arguments:"))
1374        label.grid(row=0, column=0, sticky="nse", padx=5)
1375
1376        self.program_arguments_box = ttk.Combobox(
1377            frame,
1378            width=80,
1379            height=15,
1380            textvariable=content_var,
1381            values=[""] + self.get_option("run.past_program_arguments"),
1382        )
1383        self.program_arguments_box.grid(row=0, column=1, sticky="nsew", padx=5)
1384
1385        frame.columnconfigure(1, weight=1)
1386
1387        def update_visibility():
1388            if visibility_var.get():
1389                if not frame.winfo_ismapped():
1390                    frame.grid(row=0, column=col, sticky="nse")
1391            else:
1392                if frame.winfo_ismapped():
1393                    frame.grid_remove()
1394
1395        def toggle():
1396            visibility_var.set(not visibility_var.get())
1397            update_visibility()
1398
1399        self.add_command(
1400            "viewargs",
1401            "view",
1402            tr("Program arguments"),
1403            toggle,
1404            flag_name="view.show_program_arguments",
1405            group=11,
1406        )
1407
1408        update_visibility()
1409
1410    def _init_regular_mode_link(self):
1411        if self.get_ui_mode() != "simple":
1412            return
1413
1414        label = ttk.Label(
1415            self._toolbar,
1416            text=tr("Switch to\nregular\nmode"),
1417            justify="right",
1418            font="SmallLinkFont",
1419            style="Url.TLabel",
1420            cursor="hand2",
1421        )
1422        label.grid(row=0, column=1001, sticky="ne")
1423
1424        def on_click(event):
1425            self.set_option("general.ui_mode", "regular")
1426            tk.messagebox.showinfo(
1427                tr("Regular mode"),
1428                tr(
1429                    "Configuration has been updated. "
1430                    + "Restart Thonny to start working in regular mode.\n\n"
1431                    + "(See 'Tools → Options → General' if you change your mind later.)"
1432                ),
1433                master=self,
1434            )
1435
1436        label.bind("<1>", on_click, True)
1437
1438    def _switch_backend_group(self, group):
1439        pass
1440
1441    def _switch_darkness(self, mode):
1442        pass
1443
1444    def _switch_to_regular_mode(self):
1445        pass
1446
1447    def log_program_arguments_string(self, arg_str: str) -> None:
1448        arg_str = arg_str.strip()
1449        self.set_option("run.program_arguments", arg_str)
1450
1451        if arg_str == "":
1452            # empty will be handled differently
1453            return
1454
1455        past_args = self.get_option("run.past_program_arguments")
1456
1457        if arg_str in past_args:
1458            past_args.remove(arg_str)
1459
1460        past_args.insert(0, arg_str)
1461        past_args = past_args[:10]
1462
1463        self.set_option("run.past_program_arguments", past_args)
1464        self.program_arguments_box.configure(values=[""] + past_args)
1465
1466    def _show_views(self) -> None:
1467        for view_id in self._view_records:
1468            if self._view_records[view_id]["visibility_flag"].get():
1469                try:
1470                    self.show_view(view_id, False)
1471                except Exception:
1472                    self.report_exception("Problem showing " + view_id)
1473
1474    def update_image_mapping(self, mapping: Dict[str, str]) -> None:
1475        """Was used by thonny-pi. Not recommended anymore"""
1476        self._default_image_mapping.update(mapping)
1477
1478    def get_backends(self) -> Dict[str, BackendSpec]:
1479        return self._backends
1480
1481    def get_option(self, name: str, default=None) -> Any:
1482        # Need to return Any, otherwise each typed call site needs to cast
1483        return self._configuration_manager.get_option(name, default)
1484
1485    def set_option(self, name: str, value: Any) -> None:
1486        self._configuration_manager.set_option(name, value)
1487
1488    def get_local_cwd(self) -> str:
1489        cwd = self.get_option("run.working_directory")
1490        if os.path.exists(cwd):
1491            return normpath_with_actual_case(cwd)
1492        else:
1493            return normpath_with_actual_case(os.path.expanduser("~"))
1494
1495    def set_local_cwd(self, value: str) -> None:
1496        if self.get_option("run.working_directory") != value:
1497            self.set_option("run.working_directory", value)
1498            if value:
1499                self.event_generate("LocalWorkingDirectoryChanged", cwd=value)
1500
1501    def set_default(self, name: str, default_value: Any) -> None:
1502        """Registers a new option.
1503
1504        If the name contains a period, then the part left to the (first) period
1505        will become the section of the option and rest will become name under that
1506        section.
1507
1508        If the name doesn't contain a period, then it will be added under section
1509        "general".
1510        """
1511        self._configuration_manager.set_default(name, default_value)
1512
1513    def get_variable(self, name: str) -> tk.Variable:
1514        return self._configuration_manager.get_variable(name)
1515
1516    def get_menu(self, name: str, label: Optional[str] = None) -> tk.Menu:
1517        """Gives the menu with given name. Creates if not created yet.
1518
1519        Args:
1520            name: meant to be used as not translatable menu name
1521            label: translated label, used only when menu with given name doesn't exist yet
1522        """
1523
1524        # For compatibility with plug-ins
1525        if name in ["device", "tempdevice"] and label is None:
1526            label = tr("Device")
1527
1528        if name not in self._menus:
1529            if running_on_mac_os():
1530                conf = {}
1531            else:
1532                conf = get_style_configuration("Menu")
1533
1534            menu = tk.Menu(self._menubar, **conf)
1535            menu["postcommand"] = lambda: self._update_menu(menu, name)
1536            self._menubar.add_cascade(label=label if label else name, menu=menu)
1537
1538            self._menus[name] = menu
1539            if label:
1540                self._menus[label] = menu
1541
1542        return self._menus[name]
1543
1544    def get_view(self, view_id: str, create: bool = True) -> tk.Widget:
1545        if "instance" not in self._view_records[view_id]:
1546            if not create:
1547                raise RuntimeError("View %s not created" % view_id)
1548            class_ = self._view_records[view_id]["class"]
1549            location = self._view_records[view_id]["location"]
1550            master = self._view_notebooks[location]
1551
1552            # create the view
1553            view = class_(self)  # View's master is workbench to allow making it maximized
1554            view.position_key = self._view_records[view_id]["position_key"]
1555            self._view_records[view_id]["instance"] = view
1556
1557            # create the view home_widget to be added into notebook
1558            view.home_widget = ttk.Frame(master)
1559            view.home_widget.columnconfigure(0, weight=1)
1560            view.home_widget.rowconfigure(0, weight=1)
1561            view.home_widget.maximizable_widget = view  # type: ignore
1562            view.home_widget.close = lambda: self.hide_view(view_id)  # type: ignore
1563            if hasattr(view, "position_key"):
1564                view.home_widget.position_key = view.position_key  # type: ignore
1565
1566            # initially the view will be in it's home_widget
1567            view.grid(row=0, column=0, sticky=tk.NSEW, in_=view.home_widget)
1568            view.hidden = True
1569
1570        return self._view_records[view_id]["instance"]
1571
1572    def get_editor_notebook(self) -> EditorNotebook:
1573        assert self._editor_notebook is not None
1574        return self._editor_notebook
1575
1576    def get_package_dir(self):
1577        """Returns thonny package directory"""
1578        return os.path.dirname(sys.modules["thonny"].__file__)
1579
1580    def get_image(
1581        self, filename: str, tk_name: Optional[str] = None, disabled=False
1582    ) -> tk.PhotoImage:
1583
1584        if filename in self._image_mapping_by_theme[self._current_theme_name]:
1585            filename = self._image_mapping_by_theme[self._current_theme_name][filename]
1586
1587        if filename in self._default_image_mapping:
1588            filename = self._default_image_mapping[filename]
1589
1590        # if path is relative then interpret it as living in res folder
1591        if not os.path.isabs(filename):
1592            filename = os.path.join(self.get_package_dir(), "res", filename)
1593            if not os.path.exists(filename):
1594                if os.path.exists(filename + ".png"):
1595                    filename = filename + ".png"
1596                elif os.path.exists(filename + ".gif"):
1597                    filename = filename + ".gif"
1598
1599        if disabled:
1600            filename = os.path.join(
1601                os.path.dirname(filename), "_disabled_" + os.path.basename(filename)
1602            )
1603            if not os.path.exists(filename):
1604                return None
1605
1606        # are there platform-specific variants?
1607        plat_filename = filename[:-4] + "_" + platform.system() + ".png"
1608        if os.path.exists(plat_filename):
1609            filename = plat_filename
1610
1611        if self._scaling_factor >= 2.0:
1612            scaled_filename = filename[:-4] + "_2x.png"
1613            if os.path.exists(scaled_filename):
1614                filename = scaled_filename
1615            else:
1616                img = tk.PhotoImage(file=filename)
1617                # can't use zoom method, because this doesn't allow name
1618                img2 = tk.PhotoImage(tk_name)
1619                self.tk.call(
1620                    img2,
1621                    "copy",
1622                    img.name,
1623                    "-zoom",
1624                    int(self._scaling_factor),
1625                    int(self._scaling_factor),
1626                )
1627                self._images.add(img2)
1628                return img2
1629
1630        img = tk.PhotoImage(tk_name, file=filename)
1631        self._images.add(img)
1632        return img
1633
1634    def show_view(self, view_id: str, set_focus: bool = True) -> Union[bool, tk.Widget]:
1635        """View must be already registered.
1636
1637        Args:
1638            view_id: View class name
1639            without package name (eg. 'ShellView')"""
1640
1641        if view_id == "MainFileBrowser":
1642            # Was renamed in 3.1.1
1643            view_id = "FilesView"
1644
1645        # NB! Don't forget that view.home_widget is added to notebook, not view directly
1646        # get or create
1647        view = self.get_view(view_id)
1648        notebook = view.home_widget.master  # type: ignore
1649
1650        if hasattr(view, "before_show") and view.before_show() == False:  # type: ignore
1651            return False
1652
1653        if view.hidden:  # type: ignore
1654            notebook.insert(
1655                "auto", view.home_widget, text=self._view_records[view_id]["label"]  # type: ignore
1656            )
1657            view.hidden = False  # type: ignore
1658            if hasattr(view, "on_show"):  # type: ignore
1659                view.on_show()
1660
1661        # switch to the tab
1662        notebook.select(view.home_widget)  # type: ignore
1663
1664        # add focus
1665        if set_focus:
1666            view.focus_set()
1667
1668        self.set_option("view." + view_id + ".visible", True)
1669        self.event_generate("ShowView", view=view, view_id=view_id)
1670        return view
1671
1672    def hide_view(self, view_id: str) -> Union[bool, None]:
1673        # NB! Don't forget that view.home_widget is added to notebook, not view directly
1674
1675        if "instance" in self._view_records[view_id]:
1676            # TODO: handle the case, when view is maximized
1677            view = self._view_records[view_id]["instance"]
1678            if view.hidden:
1679                return True
1680
1681            if hasattr(view, "before_hide") and view.before_hide() == False:
1682                return False
1683
1684            view.home_widget.master.forget(view.home_widget)
1685            self.set_option("view." + view_id + ".visible", False)
1686
1687            self.event_generate("HideView", view=view, view_id=view_id)
1688            view.hidden = True
1689
1690        return True
1691
1692    def event_generate(self, sequence: str, event: Optional[Record] = None, **kwargs) -> None:
1693        """Uses custom event handling when sequence doesn't start with <.
1694        In this case arbitrary attributes can be added to the event.
1695        Otherwise forwards the call to Tk's event_generate"""
1696        # pylint: disable=arguments-differ
1697        if sequence.startswith("<"):
1698            assert event is None
1699            tk.Tk.event_generate(self, sequence, **kwargs)
1700        else:
1701            if sequence in self._event_handlers:
1702                if event is None:
1703                    event = WorkbenchEvent(sequence, **kwargs)
1704                else:
1705                    event.update(kwargs)
1706
1707                # make a copy of handlers, so that event handler can remove itself
1708                # from the registry during iteration
1709                # (or new handlers can be added)
1710                for handler in sorted(self._event_handlers[sequence].copy(), key=str):
1711                    try:
1712                        handler(event)
1713                    except Exception:
1714                        self.report_exception("Problem when handling '" + sequence + "'")
1715
1716        if not self._closing:
1717            self._update_toolbar()
1718
1719    def bind(self, sequence: str, func: Callable, add: bool = None) -> None:  # type: ignore
1720        """Uses custom event handling when sequence doesn't start with <.
1721        Otherwise forwards the call to Tk's bind"""
1722        # pylint: disable=signature-differs
1723
1724        if not add:
1725            logging.warning(
1726                "Workbench.bind({}, ..., add={}) -- did you really want to replace existing bindings?".format(
1727                    sequence, add
1728                )
1729            )
1730
1731        if sequence.startswith("<"):
1732            tk.Tk.bind(self, sequence, func, add)
1733        else:
1734            if sequence not in self._event_handlers or not add:
1735                self._event_handlers[sequence] = set()
1736
1737            self._event_handlers[sequence].add(func)
1738
1739    def unbind(self, sequence: str, func=None) -> None:
1740        # pylint: disable=arguments-differ
1741        if sequence.startswith("<"):
1742            tk.Tk.unbind(self, sequence, funcid=func)
1743        else:
1744            try:
1745                self._event_handlers[sequence].remove(func)
1746            except Exception:
1747                logger.exception("Can't remove binding for '%s' and '%s'", sequence, func)
1748
1749    def in_heap_mode(self) -> bool:
1750        # TODO: add a separate command for enabling the heap mode
1751        # untie the mode from HeapView
1752
1753        return self._configuration_manager.has_option("view.HeapView.visible") and self.get_option(
1754            "view.HeapView.visible"
1755        )
1756
1757    def in_debug_mode(self) -> bool:
1758        return (
1759            os.environ.get("THONNY_DEBUG", False)
1760            in [
1761                "1",
1762                1,
1763                "True",
1764                True,
1765                "true",
1766            ]
1767            or self.get_option("general.debug_mode", False)
1768        )
1769
1770    def _init_scaling(self) -> None:
1771        self._default_scaling_factor = self.tk.call("tk", "scaling")
1772        if self._default_scaling_factor > 10:
1773            # it may be infinity in eg. Fedora
1774            self._default_scaling_factor = 1.33
1775
1776        scaling = self.get_option("general.scaling")
1777        if scaling in ["default", "auto"]:  # auto was used in 2.2b3
1778            self._scaling_factor = self._default_scaling_factor
1779        else:
1780            self._scaling_factor = float(scaling)
1781
1782        MAC_SCALING_MODIFIER = 1.7
1783        if running_on_mac_os():
1784            self._scaling_factor *= MAC_SCALING_MODIFIER
1785
1786        self.tk.call("tk", "scaling", self._scaling_factor)
1787
1788        font_scaling_mode = self.get_option("general.font_scaling_mode")
1789
1790        if (
1791            running_on_linux()
1792            and font_scaling_mode in ["default", "extra"]
1793            and scaling not in ["default", "auto"]
1794        ):
1795            # update system fonts which are given in pixel sizes
1796            for name in tk_font.names():
1797                f = tk_font.nametofont(name)
1798                orig_size = f.cget("size")
1799                # According to do documentation, absolute values of negative font sizes
1800                # should be interpreted as pixel sizes (not affected by "tk scaling")
1801                # and positive values are point sizes, which are supposed to scale automatically
1802                # http://www.tcl.tk/man/tcl8.6/TkCmd/font.htm#M26
1803
1804                # Unfortunately it seems that this cannot be relied on
1805                # https://groups.google.com/forum/#!msg/comp.lang.tcl/ZpL6tq77M4M/GXImiV2INRQJ
1806
1807                # My experiments show that manually changing negative font sizes
1808                # doesn't have any effect -- fonts keep their default size
1809                # (Tested in Raspbian Stretch, Ubuntu 18.04 and Fedora 29)
1810                # On the other hand positive sizes scale well (and they don't scale automatically)
1811
1812                # convert pixel sizes to point_size
1813                if orig_size < 0:
1814                    orig_size = -orig_size / self._default_scaling_factor
1815
1816                # scale
1817                scaled_size = round(
1818                    orig_size * (self._scaling_factor / self._default_scaling_factor)
1819                )
1820                f.configure(size=scaled_size)
1821
1822        elif running_on_mac_os() and scaling not in ["default", "auto"]:
1823            # see http://wiki.tcl.tk/44444
1824            # update system fonts
1825            for name in tk_font.names():
1826                f = tk_font.nametofont(name)
1827                orig_size = f.cget("size")
1828                assert orig_size > 0
1829                f.configure(size=int(orig_size * self._scaling_factor / MAC_SCALING_MODIFIER))
1830
1831    def update_fonts(self) -> None:
1832        editor_font_size = self._guard_font_size(self.get_option("view.editor_font_size"))
1833        editor_font_family = self.get_option("view.editor_font_family")
1834
1835        io_font_size = self._guard_font_size(self.get_option("view.io_font_size"))
1836        io_font_family = self.get_option("view.io_font_family")
1837        for io_name in [
1838            "IOFont",
1839            "BoldIOFont",
1840            "UnderlineIOFont",
1841            "ItalicIOFont",
1842            "BoldItalicIOFont",
1843        ]:
1844            tk_font.nametofont(io_name).configure(family=io_font_family, size=io_font_size)
1845
1846        try:
1847            shell = self.get_view("ShellView", create=False)
1848        except Exception:
1849            # shell may be not created yet
1850            pass
1851        else:
1852            shell.update_tabs()
1853
1854        tk_font.nametofont("EditorFont").configure(family=editor_font_family, size=editor_font_size)
1855        tk_font.nametofont("SmallEditorFont").configure(
1856            family=editor_font_family, size=editor_font_size - 2
1857        )
1858        tk_font.nametofont("BoldEditorFont").configure(
1859            family=editor_font_family, size=editor_font_size
1860        )
1861        tk_font.nametofont("ItalicEditorFont").configure(
1862            family=editor_font_family, size=editor_font_size
1863        )
1864        tk_font.nametofont("BoldItalicEditorFont").configure(
1865            family=editor_font_family, size=editor_font_size
1866        )
1867
1868        if self.get_ui_mode() == "simple":
1869            default_size_factor = max(0.7, 1 - (editor_font_size - 10) / 25)
1870            small_size_factor = max(0.6, 0.8 - (editor_font_size - 10) / 25)
1871
1872            tk_font.nametofont("TkDefaultFont").configure(
1873                size=round(editor_font_size * default_size_factor)
1874            )
1875            tk_font.nametofont("TkHeadingFont").configure(
1876                size=round(editor_font_size * default_size_factor)
1877            )
1878            tk_font.nametofont("SmallLinkFont").configure(
1879                size=round(editor_font_size * small_size_factor)
1880            )
1881
1882        # Update Treeview font and row height
1883        if running_on_mac_os():
1884            treeview_font_size = int(editor_font_size * 0.7 + 4)
1885        else:
1886            treeview_font_size = int(editor_font_size * 0.7 + 2)
1887
1888        treeview_font = tk_font.nametofont("TreeviewFont")
1889        treeview_font.configure(size=treeview_font_size)
1890        rowheight = round(treeview_font.metrics("linespace") * 1.2)
1891
1892        style = ttk.Style()
1893        style.configure("Treeview", rowheight=rowheight)
1894
1895        if self._editor_notebook is not None:
1896            self._editor_notebook.update_appearance()
1897
1898    def _get_menu_index(self, menu: tk.Menu) -> int:
1899        for i in range(len(self._menubar.winfo_children())):
1900            if menu == self._menubar.winfo_children()[i]:
1901                return i
1902
1903        raise RuntimeError("Couldn't find menu")
1904
1905    def _add_toolbar_button(
1906        self,
1907        command_id: str,
1908        image: Optional[tk.PhotoImage],
1909        disabled_image: Optional[tk.PhotoImage],
1910        command_label: str,
1911        caption: str,
1912        alternative_caption: str,
1913        accelerator: Optional[str],
1914        handler: Callable[[], None],
1915        tester: Optional[Callable[[], bool]],
1916        toolbar_group: int,
1917    ) -> None:
1918
1919        assert caption is not None and len(caption) > 0, (
1920            "Missing caption for '%s'. Toolbar commands must have caption." % command_label
1921        )
1922        slaves = self._toolbar.grid_slaves(0, toolbar_group)
1923        if len(slaves) == 0:
1924            group_frame = ttk.Frame(self._toolbar)
1925            if self.in_simple_mode():
1926                padx = 0  # type: Union[int, Tuple[int, int]]
1927            else:
1928                padx = (0, 10)
1929            group_frame.grid(row=0, column=toolbar_group, padx=padx)
1930        else:
1931            group_frame = slaves[0]
1932
1933        if self.in_simple_mode():
1934            screen_width = self.winfo_screenwidth()
1935            if screen_width >= 1280:
1936                button_width = max(7, len(caption), len(alternative_caption))
1937            elif screen_width >= 1024:
1938                button_width = max(6, len(caption), len(alternative_caption))
1939            else:
1940                button_width = max(5, len(caption), len(alternative_caption))
1941        else:
1942            button_width = None
1943
1944        if disabled_image is not None:
1945            image_spec = [image, "disabled", disabled_image]
1946        else:
1947            image_spec = image
1948
1949        button = ttk.Button(
1950            group_frame,
1951            image=image_spec,
1952            style="Toolbutton",
1953            state=tk.NORMAL,
1954            text=caption,
1955            compound="top" if self.in_simple_mode() else None,
1956            pad=(10, 0) if self.in_simple_mode() else None,
1957            width=button_width,
1958        )
1959
1960        def toolbar_handler(*args):
1961            handler(*args)
1962            self._update_toolbar()
1963            if self.focus_get() == button:
1964                # previously selected widget would be better candidate, but this is
1965                # better than button
1966                self._editor_notebook.focus_set()
1967
1968        button.configure(command=toolbar_handler)
1969
1970        button.pack(side=tk.LEFT)
1971        button.tester = tester  # type: ignore
1972        tooltip_text = command_label
1973        if self.get_ui_mode() != "simple":
1974            if accelerator and lookup_style_option(
1975                "OPTIONS", "shortcuts_in_tooltips", default=True
1976            ):
1977                tooltip_text += " (" + accelerator + ")"
1978            create_tooltip(button, tooltip_text)
1979
1980        self._toolbar_buttons[command_id] = button
1981
1982    def get_toolbar_button(self, command_id):
1983        return self._toolbar_buttons[command_id]
1984
1985    def _update_toolbar(self, event=None) -> None:
1986        if self._destroyed or not hasattr(self, "_toolbar"):
1987            return
1988
1989        if self._toolbar.winfo_ismapped():
1990            for group_frame in self._toolbar.grid_slaves(0):
1991                for button in group_frame.pack_slaves():
1992                    if thonny._runner is None or button.tester and not button.tester():
1993                        button["state"] = tk.DISABLED
1994                    else:
1995                        button["state"] = tk.NORMAL
1996
1997    def _cmd_zoom_with_mouse(self, event) -> None:
1998        if event.delta > 0:
1999            self._change_font_size(1)
2000        else:
2001            self._change_font_size(-1)
2002
2003    def _toggle_font_size(self) -> None:
2004        current_size = self.get_option("view.editor_font_size")
2005
2006        if self.winfo_screenwidth() < 1024:
2007            # assuming 32x32 icons
2008            small_size = 10
2009            medium_size = 12
2010            large_size = 14
2011        elif self.winfo_screenwidth() < 1280:
2012            # assuming 32x32 icons
2013            small_size = 12
2014            medium_size = 14
2015            large_size = 18
2016        else:
2017            small_size = 12
2018            medium_size = 16
2019            large_size = 20
2020
2021        widths = {10: 800, 12: 1050, 14: 1200, 16: 1300, 18: 1400, 20: 1650}
2022
2023        if current_size < small_size or current_size >= large_size:
2024            new_size = small_size
2025        elif current_size < medium_size:
2026            new_size = medium_size
2027        else:
2028            new_size = large_size
2029
2030        self._change_font_size(new_size - current_size)
2031
2032        new_width = min(widths[new_size], self.winfo_screenwidth())
2033        geo = re.findall(r"\d+", self.wm_geometry())
2034        self.geometry("{0}x{1}+{2}+{3}".format(new_width, geo[1], geo[2], geo[3]))
2035
2036    def _change_font_size(self, delta: int) -> None:
2037
2038        if delta != 0:
2039            editor_font_size = self.get_option("view.editor_font_size")
2040            editor_font_size += delta
2041            self.set_option("view.editor_font_size", self._guard_font_size(editor_font_size))
2042            io_font_size = self.get_option("view.io_font_size")
2043            io_font_size += delta
2044            self.set_option("view.io_font_size", self._guard_font_size(io_font_size))
2045            self.update_fonts()
2046
2047    def _guard_font_size(self, size: int) -> int:
2048        # https://bitbucket.org/plas/thonny/issues/164/negative-font-size-crashes-thonny
2049        MIN_SIZE = 4
2050        MAX_SIZE = 200
2051        if size < MIN_SIZE:
2052            return MIN_SIZE
2053        elif size > MAX_SIZE:
2054            return MAX_SIZE
2055        else:
2056            return size
2057
2058    def _check_update_window_width(self, delta: int) -> None:
2059        if not ui_utils.get_zoomed(self):
2060            self.update_idletasks()
2061            # TODO: shift to left if right edge goes away from screen
2062            # TODO: check with screen width
2063            new_geometry = "{0}x{1}+{2}+{3}".format(
2064                self.winfo_width() + delta, self.winfo_height(), self.winfo_x(), self.winfo_y()
2065            )
2066
2067            self.geometry(new_geometry)
2068
2069    def _maximize_view(self, event=None) -> None:
2070        if self._maximized_view is not None:
2071            return
2072
2073        # find the widget that can be relocated
2074        widget = self.focus_get()
2075        if isinstance(widget, (EditorNotebook, AutomaticNotebook)):
2076            current_tab = widget.get_current_child()
2077            if current_tab is None:
2078                return
2079
2080            if not hasattr(current_tab, "maximizable_widget"):
2081                return
2082
2083            widget = current_tab.maximizable_widget
2084
2085        while widget is not None:
2086            if hasattr(widget, "home_widget"):
2087                # if widget is view, then widget.master is workbench
2088                widget.grid(row=1, column=0, sticky=tk.NSEW, in_=widget.master)  # type: ignore
2089                # hide main_frame
2090                self._main_frame.grid_forget()
2091                self._maximized_view = widget
2092                self.get_variable("view.maximize_view").set(True)
2093                break
2094            else:
2095                widget = widget.master  # type: ignore
2096
2097    def _unmaximize_view(self, event=None) -> None:
2098        if self._maximized_view is None:
2099            return
2100
2101        # restore main_frame
2102        self._main_frame.grid(row=1, column=0, sticky=tk.NSEW, in_=self)
2103        # put the maximized view back to its home_widget
2104        self._maximized_view.grid(
2105            row=0, column=0, sticky=tk.NSEW, in_=self._maximized_view.home_widget  # type: ignore
2106        )
2107        self._maximized_view = None
2108        self.get_variable("view.maximize_view").set(False)
2109
2110    def show_options(self, page_key=None):
2111        dlg = ConfigurationDialog(self, self._configuration_pages)
2112        if page_key:
2113            dlg.select_page(page_key)
2114
2115        ui_utils.show_dialog(dlg)
2116
2117        if dlg.backend_restart_required:
2118            get_runner().restart_backend(False)
2119
2120    def _cmd_focus_editor(self) -> None:
2121        self.get_editor_notebook().focus_set()
2122
2123    def _cmd_focus_shell(self) -> None:
2124        self.show_view("ShellView", True)
2125        shell = get_shell()
2126        # go to the end of any current input
2127        shell.text.mark_set("insert", "end")
2128        shell.text.see("insert")
2129
2130    def _cmd_toggle_full_screen(self) -> None:
2131        """
2132        TODO: For mac
2133        http://wiki.tcl.tk/44444
2134
2135        Switching a window to fullscreen mode
2136        (Normal Difference)
2137        To switch a window to fullscreen mode, the window must first be withdrawn.
2138              # For Linux/Mac OS X:
2139
2140              set cfs [wm attributes $w -fullscreen]
2141              if { $::tcl_platform(os) eq "Darwin" } {
2142                if { $cfs == 0 } {
2143                  # optional: save the window geometry
2144                  set savevar [wm geometry $w]
2145                }
2146                wm withdraw $w
2147              }
2148              wm attributes $w -fullscreen [expr {1-$cfs}]
2149              if { $::tcl_platform(os) eq "Darwin" } {
2150                wm deiconify $w
2151                if { $cfs == 1 } {
2152                  after idle [list wm geometry $w $savevar]
2153                }
2154              }
2155
2156        """
2157        var = self.get_variable("view.full_screen")
2158        var.set(not var.get())
2159        self.attributes("-fullscreen", var.get())
2160
2161    def _cmd_toggle_maximize_view(self) -> None:
2162        if self._maximized_view is not None:
2163            self._unmaximize_view()
2164        else:
2165            self._maximize_view()
2166
2167    def _update_menu(self, menu: tk.Menu, menu_name: str) -> None:
2168        if menu.index("end") is None:
2169            return
2170
2171        for i in range(menu.index("end") + 1):
2172            item_data = menu.entryconfigure(i)
2173            if "label" in item_data:
2174                command_label = menu.entrycget(i, "label")
2175                if (menu_name, command_label) not in self._menu_item_specs:
2176                    continue
2177                tester = self._menu_item_specs[(menu_name, command_label)].tester
2178
2179                enabled = not tester
2180                if tester:
2181                    try:
2182                        enabled = tester()
2183                    except Exception as e:
2184                        logging.exception(
2185                            "Could not check command tester for '%s'", item_data, exc_info=e
2186                        )
2187                        traceback.print_exc()
2188                        enabled = False
2189
2190                if enabled:
2191                    menu.entryconfigure(i, state=tk.NORMAL)
2192                else:
2193                    menu.entryconfigure(i, state=tk.DISABLED)
2194
2195    def _find_location_for_menu_item(self, menu_name: str, command_label: str) -> Union[str, int]:
2196
2197        menu = self.get_menu(menu_name)
2198
2199        if menu.index("end") == None:  # menu is empty
2200            return "end"
2201
2202        specs = self._menu_item_specs[(menu_name, command_label)]
2203
2204        this_group_exists = False
2205        for i in range(0, menu.index("end") + 1):
2206            data = menu.entryconfigure(i)
2207            if "label" in data:
2208                # it's a command, not separator
2209                sibling_label = menu.entrycget(i, "label")
2210                sibling_group = self._menu_item_specs[(menu_name, sibling_label)].group
2211
2212                if sibling_group == specs.group:
2213                    this_group_exists = True
2214                    if specs.position_in_group == "alphabetic" and sibling_label > command_label:
2215                        return i
2216
2217                if sibling_group > specs.group:
2218                    assert (
2219                        not this_group_exists
2220                    )  # otherwise we would have found the ending separator
2221                    menu.insert_separator(i)
2222                    return i
2223            else:
2224                # We found a separator
2225                if this_group_exists:
2226                    # it must be the ending separator for this group
2227                    return i
2228
2229        # no group was bigger, ie. this should go to the end
2230        if not this_group_exists:
2231            menu.add_separator()
2232
2233        return "end"
2234
2235    def _poll_ipc_requests(self) -> None:
2236        try:
2237            if self._ipc_requests.empty():
2238                return
2239
2240            while not self._ipc_requests.empty():
2241                args = self._ipc_requests.get()
2242                try:
2243                    for filename in args:
2244                        if os.path.isfile(filename):
2245                            self.get_editor_notebook().show_file(filename)
2246
2247                except Exception as e:
2248                    logger.exception("Problem processing ipc request", exc_info=e)
2249
2250            self.become_active_window()
2251        finally:
2252            self.after(50, self._poll_ipc_requests)
2253
2254    def _on_close(self) -> None:
2255        if self._editor_notebook and not self._editor_notebook.check_allow_closing():
2256            return
2257
2258        self._closing = True
2259        try:
2260            self._save_layout()
2261            self._editor_notebook.remember_open_files()
2262            self.event_generate("WorkbenchClose")
2263            self._configuration_manager.save()
2264            temp_dir = self.get_temp_dir(create_if_doesnt_exist=False)
2265            if os.path.exists(temp_dir):
2266                try:
2267                    shutil.rmtree(temp_dir)
2268                except Exception as e:
2269                    logger.error("Could not remove temp dir", exc_info=e)
2270        except Exception:
2271            self.report_exception()
2272
2273        self.destroy()
2274        self._destroyed = True
2275
2276    def _on_all_key_presses(self, event):
2277        if running_on_windows():
2278            ui_utils.handle_mistreated_latin_shortcuts(self._latin_shortcuts, event)
2279
2280    def _on_focus_in(self, event):
2281        if self._lost_focus:
2282            self._lost_focus = False
2283            self.event_generate("WindowFocusIn")
2284
2285    def _on_focus_out(self, event):
2286        if self.focus_get() is None:
2287            if not self._lost_focus:
2288                self._lost_focus = True
2289                self.event_generate("WindowFocusOut")
2290
2291    def focus_get(self) -> Optional[tk.Widget]:
2292        try:
2293            return tk.Tk.focus_get(self)
2294        except Exception:
2295            # This may give error in Ubuntu
2296            return None
2297
2298    def destroy(self) -> None:
2299        try:
2300            if self._is_server() and os.path.exists(thonny.get_ipc_file_path()):
2301                os.remove(thonny.get_ipc_file_path())
2302
2303            self._closing = True
2304
2305            # Tk clipboard gets cleared on exit and won't end up in system clipboard
2306            # https://bugs.python.org/issue1207592
2307            # https://stackoverflow.com/questions/26321333/tkinter-in-python-3-4-on-windows-dont-post-internal-clipboard-data-to-the-windo
2308            try:
2309                clipboard_data = self.clipboard_get()
2310                if len(clipboard_data) < 1000 and all(
2311                    map(os.path.exists, clipboard_data.splitlines())
2312                ):
2313                    # Looks like the clipboard contains file name(s)
2314                    # Most likely this means actual file cut/copy operation
2315                    # was made outside of Thonny.
2316                    # Don't want to replace this with simple string data of file names.
2317                    pass
2318                else:
2319                    copy_to_clipboard(clipboard_data)
2320            except Exception:
2321                pass
2322
2323        except Exception:
2324            logging.exception("Error while destroying workbench")
2325
2326        finally:
2327            try:
2328                super().destroy()
2329            finally:
2330                runner = get_runner()
2331                if runner != None:
2332                    runner.destroy_backend()
2333
2334    def _on_configure(self, event) -> None:
2335        # called when window is moved or resized
2336        if (
2337            hasattr(self, "_maximized_view")  # configure may happen before the attribute is defined
2338            and self._maximized_view  # type: ignore
2339        ):
2340            # grid again, otherwise it acts weird
2341            self._maximized_view.grid(
2342                row=1, column=0, sticky=tk.NSEW, in_=self._maximized_view.master  # type: ignore
2343            )
2344
2345    def _on_tk_exception(self, exc, val, tb) -> None:
2346        # copied from tkinter.Tk.report_callback_exception with modifications
2347        # see http://bugs.python.org/issue22384
2348        sys.last_type = exc
2349        sys.last_value = val
2350        sys.last_traceback = tb
2351        self.report_exception()
2352
2353    def report_exception(self, title: str = "Internal error") -> None:
2354        logging.exception(title)
2355        if tk._default_root and not self._closing:  # type: ignore
2356            (typ, value, _) = sys.exc_info()
2357            assert typ is not None
2358            if issubclass(typ, UserError):
2359                msg = str(value)
2360            else:
2361                msg = traceback.format_exc()
2362
2363            dlg = ui_utils.LongTextDialog(title, msg, parent=self)
2364            ui_utils.show_dialog(dlg, self)
2365
2366    def _open_views(self) -> None:
2367        for nb_name in self._view_notebooks:
2368            view_name = self.get_option("layout.notebook_" + nb_name + "_visible_view")
2369            if view_name != None:
2370                if view_name == "GlobalsView":
2371                    # was renamed in 2.2b5
2372                    view_name = "VariablesView"
2373
2374                if (
2375                    self.get_ui_mode() != "simple" or view_name in SIMPLE_MODE_VIEWS
2376                ) and view_name in self._view_records:
2377                    self.show_view(view_name)
2378
2379        # make sure VariablesView is at least loaded
2380        # otherwise it may miss globals events
2381        # and will show empty table on open
2382        self.get_view("VariablesView")
2383
2384        if (
2385            self.get_option("assistance.open_assistant_on_errors")
2386            or self.get_option("assistance.open_assistant_on_warnings")
2387        ) and (self.get_ui_mode() != "simple" or "AssistantView" in SIMPLE_MODE_VIEWS):
2388            self.get_view("AssistantView")
2389
2390    def _save_layout(self) -> None:
2391        self.update_idletasks()
2392        self.set_option("layout.zoomed", ui_utils.get_zoomed(self))
2393
2394        for nb_name in self._view_notebooks:
2395            widget = self._view_notebooks[nb_name].get_visible_child()
2396            if hasattr(widget, "maximizable_widget"):
2397                view = widget.maximizable_widget
2398                view_name = type(view).__name__
2399                self.set_option("layout.notebook_" + nb_name + "_visible_view", view_name)
2400            else:
2401                self.set_option("layout.notebook_" + nb_name + "_visible_view", None)
2402
2403        if not ui_utils.get_zoomed(self) or running_on_mac_os():
2404            # can't restore zoom on mac without setting actual dimensions
2405            gparts = re.findall(r"\d+", self.wm_geometry())
2406            self.set_option("layout.width", int(gparts[0]))
2407            self.set_option("layout.height", int(gparts[1]))
2408            self.set_option("layout.left", int(gparts[2]))
2409            self.set_option("layout.top", int(gparts[3]))
2410
2411        self.set_option("layout.west_pw_width", self._west_pw.preferred_size_in_pw)
2412        self.set_option("layout.east_pw_width", self._east_pw.preferred_size_in_pw)
2413        for key in ["nw", "sw", "s", "se", "ne"]:
2414            self.set_option(
2415                "layout.%s_nb_height" % key, self._view_notebooks[key].preferred_size_in_pw
2416            )
2417
2418    def update_title(self, event=None) -> None:
2419        editor = self.get_editor_notebook().get_current_editor()
2420        if self._is_portable:
2421            title_text = "Portable Thonny"
2422        else:
2423            title_text = "Thonny"
2424        if editor != None:
2425            title_text += "  -  " + editor.get_long_description()
2426
2427        self.title(title_text)
2428
2429    def become_active_window(self, force=True) -> None:
2430        # Looks like at least on Windows all following is required
2431        # for ensuring the window gets focus
2432        # (deiconify, ..., iconify, deiconify)
2433        self.deiconify()
2434
2435        if force:
2436            self.attributes("-topmost", True)
2437            self.after_idle(self.attributes, "-topmost", False)
2438            self.lift()
2439
2440            if not running_on_linux():
2441                # http://stackoverflow.com/a/13867710/261181
2442                self.iconify()
2443                self.deiconify()
2444
2445        editor = self.get_editor_notebook().get_current_editor()
2446        if editor is not None:
2447            # This method is meant to be called when new file is opened, so it's safe to
2448            # send the focus to the editor
2449            editor.focus_set()
2450        else:
2451            self.focus_set()
2452
2453    def open_url(self, url):
2454        m = re.match(r"^thonny-editor://(.*?)(#(\d+)(:(\d+))?)?$", url)
2455        if m is not None:
2456            filename = m.group(1).replace("%20", " ")
2457            lineno = None if m.group(3) is None else int(m.group(3))
2458            col_offset = None if m.group(5) is None else int(m.group(5))
2459            if lineno is None:
2460                self.get_editor_notebook().show_file(filename)
2461            else:
2462                self.get_editor_notebook().show_file_at_line(filename, lineno, col_offset)
2463
2464            return
2465
2466        m = re.match(r"^thonny-help://(.*?)(#(.+))?$", url)
2467        if m is not None:
2468            topic = m.group(1)
2469            fragment = m.group(3)
2470            self.show_view("HelpView").load_topic(topic, fragment)
2471            return
2472
2473        if url.endswith(".rst") and not url.startswith("http"):
2474            parts = url.split("#", maxsplit=1)
2475            topic = parts[0][:-4]
2476            if len(parts) == 2:
2477                fragment = parts[1]
2478            else:
2479                fragment = None
2480
2481            self.show_view("HelpView").load_topic(topic, fragment)
2482            return
2483
2484        # Fallback
2485        import webbrowser
2486
2487        webbrowser.open(url, False, True)
2488
2489    def open_help_topic(self, topic, fragment=None):
2490        self.show_view("HelpView").load_topic(topic, fragment)
2491
2492    def bell(self, displayof=0):
2493        if not self.get_option("general.disable_notification_sound"):
2494            super().bell(displayof=displayof)
2495
2496    def _mac_quit(self, *args):
2497        self._on_close()
2498
2499    def _is_server(self):
2500        return self._ipc_requests is not None
2501
2502    def get_toolbar(self):
2503        return self._toolbar
2504
2505    def get_temp_dir(self, create_if_doesnt_exist=True):
2506        path = os.path.join(THONNY_USER_DIR, "temp")
2507        if create_if_doesnt_exist:
2508            os.makedirs(path, exist_ok=True)
2509        return path
2510
2511    def _init_hooks(self):
2512        self._save_hooks = []
2513        self._load_hooks = []
2514
2515    def append_save_hook(self, callback):
2516        self._save_hooks.append(callback)
2517
2518    def append_load_hook(self, callback):
2519        self._load_hooks.append(callback)
2520
2521    def iter_save_hooks(self):
2522        return iter(self._save_hooks)
2523
2524    def iter_load_hooks(self):
2525        return iter(self._load_hooks)
2526
2527
2528class WorkbenchEvent(Record):
2529    def __init__(self, sequence: str, **kwargs) -> None:
2530        Record.__init__(self, **kwargs)
2531        self.sequence = sequence
2532