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