1# -*- coding: utf-8 -*- 2import collections 3import logging 4import os 5import platform 6import queue 7import re 8import signal 9import subprocess 10import sys 11import textwrap 12import threading 13import time 14import tkinter as tk 15import tkinter.font 16import traceback 17import warnings 18from tkinter import filedialog, messagebox, ttk 19from typing import Callable, List, Optional, Tuple, Union # @UnusedImport 20 21from _tkinter import TclError 22 23from thonny import get_workbench, misc_utils, tktextext 24from thonny.common import TextRange 25from thonny.languages import get_button_padding, tr 26from thonny.misc_utils import ( 27 running_on_linux, 28 running_on_mac_os, 29 running_on_rpi, 30 running_on_windows, 31) 32from thonny.tktextext import TweakableText 33 34PARENS_REGEX = re.compile(r"[\(\)\{\}\[\]]") 35 36logger = logging.getLogger(__name__) 37 38 39class CommonDialog(tk.Toplevel): 40 def __init__(self, master=None, cnf={}, **kw): 41 super().__init__(master=master, cnf=cnf, **kw) 42 self.bind("<FocusIn>", self._unlock_on_focus_in, True) 43 44 def _unlock_on_focus_in(self, event): 45 if not self.winfo_ismapped(): 46 focussed_widget = self.focus_get() 47 self.deiconify() 48 if focussed_widget: 49 focussed_widget.focus_set() 50 51 def get_padding(self): 52 return ems_to_pixels(2) 53 54 def get_internal_padding(self): 55 return self.get_padding() // 4 56 57 58class CommonDialogEx(CommonDialog): 59 def __init__(self, master=None, cnf={}, **kw): 60 super().__init__(master=master, cnf=cnf, **kw) 61 62 # Need to fill the dialog with a frame to gain theme support 63 self.main_frame = ttk.Frame(self) 64 self.main_frame.grid(row=0, column=0, sticky="nsew") 65 self.columnconfigure(0, weight=1) 66 self.rowconfigure(0, weight=1) 67 68 self.bind("<Escape>", self.on_close, True) 69 self.protocol("WM_DELETE_WINDOW", self.on_close) 70 71 def on_close(self, event=None): 72 self.destroy() 73 74 75class QueryDialog(CommonDialogEx): 76 def __init__( 77 self, 78 master, 79 title: str, 80 prompt: str, 81 initial_value: str = "", 82 options: List[str] = [], 83 entry_width: Optional[int] = None, 84 ): 85 super().__init__(master) 86 self.var = tk.StringVar(value=initial_value) 87 self.result = None 88 89 margin = self.get_padding() 90 spacing = margin // 2 91 92 self.title(title) 93 self.prompt_label = ttk.Label(self.main_frame, text=prompt) 94 self.prompt_label.grid(row=1, column=1, columnspan=2, padx=margin, pady=(margin, spacing)) 95 96 if options: 97 self.entry_widget = ttk.Combobox( 98 self.main_frame, textvariable=self.var, values=options, height=15, width=entry_width 99 ) 100 else: 101 self.entry_widget = ttk.Entry(self.main_frame, textvariable=self.var, width=entry_width) 102 103 self.entry_widget.bind("<Return>", self.on_ok, True) 104 self.entry_widget.bind("<KP_Enter>", self.on_ok, True) 105 106 self.entry_widget.grid( 107 row=3, column=1, columnspan=2, sticky="we", padx=margin, pady=(0, margin) 108 ) 109 110 self.ok_button = ttk.Button( 111 self.main_frame, text=tr("OK"), command=self.on_ok, default="active" 112 ) 113 self.ok_button.grid(row=5, column=1, padx=(margin, spacing), pady=(0, margin), sticky="e") 114 self.cancel_button = ttk.Button(self.main_frame, text=tr("Cancel"), command=self.on_cancel) 115 self.cancel_button.grid(row=5, column=2, padx=(0, margin), pady=(0, margin), sticky="e") 116 117 self.main_frame.columnconfigure(1, weight=1) 118 119 self.entry_widget.focus_set() 120 121 def on_ok(self, event=None): 122 self.result = self.var.get() 123 self.destroy() 124 125 def on_cancel(self, event=None): 126 self.result = None 127 self.destroy() 128 129 def get_result(self) -> Optional[str]: 130 return self.result 131 132 133def ask_string( 134 title: str, 135 prompt: str, 136 initial_value: str = "", 137 options: List[str] = [], 138 entry_width: Optional[int] = None, 139 master=None, 140): 141 dlg = QueryDialog( 142 master, title, prompt, initial_value=initial_value, options=options, entry_width=entry_width 143 ) 144 show_dialog(dlg, master) 145 return dlg.get_result() 146 147 148class CustomMenubar(ttk.Frame): 149 def __init__(self, master): 150 ttk.Frame.__init__(self, master, style="CustomMenubar.TFrame") 151 self._menus = [] 152 self._opened_menu = None 153 154 ttk.Style().map( 155 "CustomMenubarLabel.TLabel", 156 background=[ 157 ("!active", lookup_style_option("Menubar", "background", "gray")), 158 ("active", lookup_style_option("Menubar", "activebackground", "LightYellow")), 159 ], 160 foreground=[ 161 ("!active", lookup_style_option("Menubar", "foreground", "black")), 162 ("active", lookup_style_option("Menubar", "activeforeground", "black")), 163 ], 164 ) 165 166 def add_cascade(self, label, menu): 167 label_widget = ttk.Label( 168 self, 169 style="CustomMenubarLabel.TLabel", 170 text=label, 171 padding=[6, 3, 6, 2], 172 font="TkDefaultFont", 173 ) 174 175 if len(self._menus) == 0: 176 padx = (6, 0) 177 else: 178 padx = 0 179 180 label_widget.grid(row=0, column=len(self._menus), padx=padx) 181 182 def enter(event): 183 label_widget.state(("active",)) 184 185 # Don't know how to open this menu when another menu is open 186 # another tk_popup just doesn't work unless old menu is closed by click or Esc 187 # https://stackoverflow.com/questions/38081470/is-there-a-way-to-know-if-tkinter-optionmenu-dropdown-is-active 188 # unpost doesn't work in Win and Mac: https://www.tcl.tk/man/tcl8.5/TkCmd/menu.htm#M62 189 # print("ENTER", menu, self._opened_menu) 190 if self._opened_menu is not None: 191 self._opened_menu.unpost() 192 click(event) 193 194 def leave(event): 195 label_widget.state(("!active",)) 196 197 def click(event): 198 try: 199 # print("Before") 200 self._opened_menu = menu 201 menu.tk_popup( 202 label_widget.winfo_rootx(), 203 label_widget.winfo_rooty() + label_widget.winfo_height(), 204 ) 205 finally: 206 # print("After") 207 self._opened_menu = None 208 209 label_widget.bind("<Enter>", enter, True) 210 label_widget.bind("<Leave>", leave, True) 211 label_widget.bind("<1>", click, True) 212 self._menus.append(menu) 213 214 215class AutomaticPanedWindow(tk.PanedWindow): 216 """ 217 Enables inserting panes according to their position_key-s. 218 Automatically adds/removes itself to/from its master AutomaticPanedWindow. 219 Fixes some style glitches. 220 """ 221 222 def __init__(self, master, position_key=None, preferred_size_in_pw=None, **kwargs): 223 tk.PanedWindow.__init__(self, master, border=0, **kwargs) 224 self._pane_minsize = 100 225 self.position_key = position_key 226 self._restoring_pane_sizes = False 227 228 self._last_window_size = (0, 0) 229 self._full_size_not_final = True 230 self._configure_binding = self.bind("<Configure>", self._on_window_resize, True) 231 self._update_appearance_binding = self.bind( 232 "<<ThemeChanged>>", self._update_appearance, True 233 ) 234 self.bind("<B1-Motion>", self._on_mouse_dragged, True) 235 self._update_appearance() 236 237 # should be in the end, so that it can be detected when 238 # constructor hasn't completed yet 239 self.preferred_size_in_pw = preferred_size_in_pw 240 241 def insert(self, pos, child, **kw): 242 kw.setdefault("minsize", self._pane_minsize) 243 244 if pos == "auto": 245 # According to documentation I should use self.panes() 246 # but this doesn't return expected widgets 247 for sibling in sorted( 248 self.pane_widgets(), 249 key=lambda p: p.position_key if hasattr(p, "position_key") else 0, 250 ): 251 if ( 252 not hasattr(sibling, "position_key") 253 or sibling.position_key == None 254 or sibling.position_key > child.position_key 255 ): 256 pos = sibling 257 break 258 else: 259 pos = "end" 260 261 if isinstance(pos, tk.Widget): 262 kw["before"] = pos 263 264 self.add(child, **kw) 265 266 def add(self, child, **kw): 267 kw.setdefault("minsize", self._pane_minsize) 268 269 tk.PanedWindow.add(self, child, **kw) 270 self._update_visibility() 271 self._check_restore_preferred_sizes() 272 273 def remove(self, child): 274 tk.PanedWindow.remove(self, child) 275 self._update_visibility() 276 self._check_restore_preferred_sizes() 277 278 def forget(self, child): 279 tk.PanedWindow.forget(self, child) 280 self._update_visibility() 281 self._check_restore_preferred_sizes() 282 283 def destroy(self): 284 self.unbind("<Configure>", self._configure_binding) 285 self.unbind("<<ThemeChanged>>", self._update_appearance_binding) 286 tk.PanedWindow.destroy(self) 287 288 def is_visible(self): 289 if not isinstance(self.master, AutomaticPanedWindow): 290 return self.winfo_ismapped() 291 else: 292 return self in self.master.pane_widgets() 293 294 def pane_widgets(self): 295 result = [] 296 for pane in self.panes(): 297 # pane is not the widget but some kind of reference object 298 assert not isinstance(pane, tk.Widget) 299 result.append(self.nametowidget(str(pane))) 300 return result 301 302 def _on_window_resize(self, event): 303 if event.width < 10 or event.height < 10: 304 return 305 window = self.winfo_toplevel() 306 window_size = (window.winfo_width(), window.winfo_height()) 307 initializing = hasattr(window, "initializing") and window.initializing 308 309 if ( 310 not initializing 311 and not self._restoring_pane_sizes 312 and (window_size != self._last_window_size or self._full_size_not_final) 313 ): 314 self._check_restore_preferred_sizes() 315 self._last_window_size = window_size 316 317 def _on_mouse_dragged(self, event): 318 if event.widget == self and not self._restoring_pane_sizes: 319 self._update_preferred_sizes() 320 321 def _update_preferred_sizes(self): 322 for pane in self.pane_widgets(): 323 if getattr(pane, "preferred_size_in_pw", None) is not None: 324 if self.cget("orient") == "horizontal": 325 current_size = pane.winfo_width() 326 else: 327 current_size = pane.winfo_height() 328 329 if current_size > 20: 330 pane.preferred_size_in_pw = current_size 331 332 # paneconfig width/height effectively puts 333 # unexplainable maxsize to some panes 334 # if self.cget("orient") == "horizontal": 335 # self.paneconfig(pane, width=current_size) 336 # else: 337 # self.paneconfig(pane, height=current_size) 338 # 339 # else: 340 # self.paneconfig(pane, width=1000, height=1000) 341 342 def _check_restore_preferred_sizes(self): 343 window = self.winfo_toplevel() 344 if getattr(window, "initializing", False): 345 return 346 347 try: 348 self._restoring_pane_sizes = True 349 self._restore_preferred_sizes() 350 finally: 351 self._restoring_pane_sizes = False 352 353 def _restore_preferred_sizes(self): 354 total_preferred_size = 0 355 panes_without_preferred_size = [] 356 357 panes = self.pane_widgets() 358 for pane in panes: 359 if not hasattr(pane, "preferred_size_in_pw"): 360 # child isn't fully constructed yet 361 return 362 363 if pane.preferred_size_in_pw is None: 364 panes_without_preferred_size.append(pane) 365 # self.paneconfig(pane, width=1000, height=1000) 366 else: 367 total_preferred_size += pane.preferred_size_in_pw 368 369 # Without updating pane width/height attribute 370 # the preferred size may lose effect when squeezing 371 # non-preferred panes too small. Also zooming/unzooming 372 # changes the supposedly fixed panes ... 373 # 374 # but 375 # paneconfig width/height effectively puts 376 # unexplainable maxsize to some panes 377 # if self.cget("orient") == "horizontal": 378 # self.paneconfig(pane, width=pane.preferred_size_in_pw) 379 # else: 380 # self.paneconfig(pane, height=pane.preferred_size_in_pw) 381 382 assert len(panes_without_preferred_size) <= 1 383 384 size = self._get_size() 385 if size is None: 386 return 387 388 leftover_size = self._get_size() - total_preferred_size 389 used_size = 0 390 for i, pane in enumerate(panes[:-1]): 391 used_size += pane.preferred_size_in_pw or leftover_size 392 self._place_sash(i, used_size) 393 used_size += int(str(self.cget("sashwidth"))) 394 395 def _get_size(self): 396 if self.cget("orient") == tk.HORIZONTAL: 397 result = self.winfo_width() 398 else: 399 result = self.winfo_height() 400 401 if result < 20: 402 # Not ready yet 403 return None 404 else: 405 return result 406 407 def _place_sash(self, i, distance): 408 if self.cget("orient") == tk.HORIZONTAL: 409 self.sash_place(i, distance, 0) 410 else: 411 self.sash_place(i, 0, distance) 412 413 def _update_visibility(self): 414 if not isinstance(self.master, AutomaticPanedWindow): 415 return 416 417 if len(self.panes()) == 0 and self.is_visible(): 418 self.master.forget(self) 419 420 if len(self.panes()) > 0 and not self.is_visible(): 421 self.master.insert("auto", self) 422 423 def _update_appearance(self, event=None): 424 self.configure(sashwidth=lookup_style_option("Sash", "sashthickness", ems_to_pixels(0.6))) 425 self.configure(background=lookup_style_option("TPanedWindow", "background")) 426 427 428class ClosableNotebook(ttk.Notebook): 429 def __init__(self, master, style="ButtonNotebook.TNotebook", **kw): 430 super().__init__(master, style=style, **kw) 431 432 self.tab_menu = self.create_tab_menu() 433 self._popup_index = None 434 self.pressed_index = None 435 436 self.bind("<ButtonPress-1>", self._letf_btn_press, True) 437 self.bind("<ButtonRelease-1>", self._left_btn_release, True) 438 if running_on_mac_os(): 439 self.bind("<ButtonPress-2>", self._right_btn_press, True) 440 self.bind("<Control-Button-1>", self._right_btn_press, True) 441 else: 442 self.bind("<ButtonPress-3>", self._right_btn_press, True) 443 444 # self._check_update_style() 445 446 def create_tab_menu(self): 447 menu = tk.Menu(self.winfo_toplevel(), tearoff=False, **get_style_configuration("Menu")) 448 menu.add_command(label=tr("Close"), command=self._close_tab_from_menu) 449 menu.add_command(label=tr("Close others"), command=self._close_other_tabs) 450 menu.add_command(label=tr("Close all"), command=self.close_tabs) 451 return menu 452 453 def _letf_btn_press(self, event): 454 try: 455 elem = self.identify(event.x, event.y) 456 index = self.index("@%d,%d" % (event.x, event.y)) 457 458 if "closebutton" in elem: 459 self.state(["pressed"]) 460 self.pressed_index = index 461 except Exception: 462 # may fail, if clicked outside of tab 463 return 464 465 def _left_btn_release(self, event): 466 if not self.instate(["pressed"]): 467 return 468 469 try: 470 elem = self.identify(event.x, event.y) 471 index = self.index("@%d,%d" % (event.x, event.y)) 472 except Exception: 473 # may fail, when mouse is dragged 474 return 475 else: 476 if "closebutton" in elem and self.pressed_index == index: 477 self.close_tab(index) 478 479 self.state(["!pressed"]) 480 finally: 481 self.pressed_index = None 482 483 def _right_btn_press(self, event): 484 try: 485 index = self.index("@%d,%d" % (event.x, event.y)) 486 self._popup_index = index 487 self.tab_menu.tk_popup(*self.winfo_toplevel().winfo_pointerxy()) 488 except Exception: 489 logging.exception("Opening tab menu") 490 491 def _close_tab_from_menu(self): 492 self.close_tab(self._popup_index) 493 494 def _close_other_tabs(self): 495 self.close_tabs(self._popup_index) 496 497 def close_tabs(self, except_index=None): 498 for tab_index in reversed(range(len(self.winfo_children()))): 499 if except_index is not None and tab_index == except_index: 500 continue 501 else: 502 self.close_tab(tab_index) 503 504 def close_tab(self, index): 505 child = self.get_child_by_index(index) 506 if hasattr(child, "close"): 507 child.close() 508 else: 509 self.forget(index) 510 child.destroy() 511 512 def get_child_by_index(self, index): 513 tab_id = self.tabs()[index] 514 if tab_id: 515 return self.nametowidget(tab_id) 516 else: 517 return None 518 519 def get_current_child(self): 520 child_id = self.select() 521 if child_id: 522 return self.nametowidget(child_id) 523 else: 524 return None 525 526 def focus_set(self): 527 editor = self.get_current_child() 528 if editor: 529 editor.focus_set() 530 else: 531 super().focus_set() 532 533 def _check_update_style(self): 534 style = ttk.Style() 535 if "closebutton" in style.element_names(): 536 # It's done already 537 return 538 539 # respect if required images have been defined already 540 if "img_close" not in self.image_names(): 541 img_dir = os.path.join(os.path.dirname(__file__), "res") 542 ClosableNotebook._close_img = tk.PhotoImage( 543 "img_tab_close", file=os.path.join(img_dir, "tab_close.gif") 544 ) 545 ClosableNotebook._close_active_img = tk.PhotoImage( 546 "img_tab_close_active", file=os.path.join(img_dir, "tab_close_active.gif") 547 ) 548 549 style.element_create( 550 "closebutton", 551 "image", 552 "img_tab_close", 553 ("active", "pressed", "!disabled", "img_tab_close_active"), 554 ("active", "!disabled", "img_tab_close_active"), 555 border=8, 556 sticky="", 557 ) 558 559 style.layout( 560 "ButtonNotebook.TNotebook.Tab", 561 [ 562 ( 563 "Notebook.tab", 564 { 565 "sticky": "nswe", 566 "children": [ 567 ( 568 "Notebook.padding", 569 { 570 "side": "top", 571 "sticky": "nswe", 572 "children": [ 573 ( 574 "Notebook.focus", 575 { 576 "side": "top", 577 "sticky": "nswe", 578 "children": [ 579 ( 580 "Notebook.label", 581 {"side": "left", "sticky": ""}, 582 ), 583 ( 584 "Notebook.closebutton", 585 {"side": "left", "sticky": ""}, 586 ), 587 ], 588 }, 589 ) 590 ], 591 }, 592 ) 593 ], 594 }, 595 ) 596 ], 597 ) 598 599 def _check_remove_padding(self, kw): 600 # Windows themes produce 1-pixel padding to the bottom of the pane 601 # Don't know how to get rid of it using themes 602 if "padding" not in kw and ttk.Style().theme_use().lower() in ( 603 "windows", 604 "xpnative", 605 "vista", 606 ): 607 kw["padding"] = (0, 0, 0, -1) 608 609 def add(self, child, **kw): 610 self._check_remove_padding(kw) 611 super().add(child, **kw) 612 613 def insert(self, pos, child, **kw): 614 self._check_remove_padding(kw) 615 super().insert(pos, child, **kw) 616 617 618class AutomaticNotebook(ClosableNotebook): 619 """ 620 Enables inserting views according to their position keys. 621 Remember its own position key. Automatically updates its visibility. 622 """ 623 624 def __init__(self, master, position_key, preferred_size_in_pw=None): 625 if get_workbench().in_simple_mode(): 626 style = "TNotebook" 627 else: 628 style = "ButtonNotebook.TNotebook" 629 super().__init__(master, style=style, padding=0) 630 self.position_key = position_key 631 632 # should be in the end, so that it can be detected when 633 # constructor hasn't completed yet 634 self.preferred_size_in_pw = preferred_size_in_pw 635 636 def add(self, child, **kw): 637 super().add(child, **kw) 638 self._update_visibility() 639 640 def insert(self, pos, child, **kw): 641 if pos == "auto": 642 for sibling in map(self.nametowidget, self.tabs()): 643 if ( 644 not hasattr(sibling, "position_key") 645 or sibling.position_key == None 646 or sibling.position_key > child.position_key 647 ): 648 pos = sibling 649 break 650 else: 651 pos = "end" 652 653 super().insert(pos, child, **kw) 654 self._update_visibility() 655 656 def hide(self, tab_id): 657 super().hide(tab_id) 658 self._update_visibility() 659 660 def forget(self, tab_id): 661 if tab_id in self.tabs() or tab_id in self.winfo_children(): 662 super().forget(tab_id) 663 self._update_visibility() 664 665 def is_visible(self): 666 return self in self.master.pane_widgets() 667 668 def get_visible_child(self): 669 for child in self.winfo_children(): 670 if str(child) == str(self.select()): 671 return child 672 673 return None 674 675 def _update_visibility(self): 676 if not isinstance(self.master, AutomaticPanedWindow): 677 return 678 if len(self.tabs()) == 0 and self.is_visible(): 679 self.master.remove(self) 680 681 if len(self.tabs()) > 0 and not self.is_visible(): 682 self.master.insert("auto", self) 683 684 685class TreeFrame(ttk.Frame): 686 def __init__( 687 self, 688 master, 689 columns, 690 displaycolumns="#all", 691 show_scrollbar=True, 692 show_statusbar=False, 693 borderwidth=0, 694 relief="flat", 695 **tree_kw 696 ): 697 ttk.Frame.__init__(self, master, borderwidth=borderwidth, relief=relief) 698 # http://wiki.tcl.tk/44444#pagetoc50f90d9a 699 self.vert_scrollbar = ttk.Scrollbar( 700 self, orient=tk.VERTICAL, style=scrollbar_style("Vertical") 701 ) 702 if show_scrollbar: 703 self.vert_scrollbar.grid( 704 row=0, column=1, sticky=tk.NSEW, rowspan=2 if show_statusbar else 1 705 ) 706 707 self.tree = ttk.Treeview( 708 self, 709 columns=columns, 710 displaycolumns=displaycolumns, 711 yscrollcommand=self.vert_scrollbar.set, 712 **tree_kw 713 ) 714 self.tree["show"] = "headings" 715 self.tree.grid(row=0, column=0, sticky=tk.NSEW) 716 self.vert_scrollbar["command"] = self.tree.yview 717 self.columnconfigure(0, weight=1) 718 self.rowconfigure(0, weight=1) 719 self.tree.bind("<<TreeviewSelect>>", self.on_select, "+") 720 self.tree.bind("<Double-Button-1>", self.on_double_click, "+") 721 722 self.error_label = ttk.Label(self.tree) 723 724 if show_statusbar: 725 self.statusbar = ttk.Frame(self) 726 self.statusbar.grid(row=1, column=0, sticky="nswe") 727 else: 728 self.statusbar = None 729 730 def _clear_tree(self): 731 for child_id in self.tree.get_children(): 732 self.tree.delete(child_id) 733 734 def clear(self): 735 self._clear_tree() 736 737 def on_select(self, event): 738 pass 739 740 def on_double_click(self, event): 741 pass 742 743 def show_error(self, error_text): 744 self.error_label.configure(text=error_text) 745 self.error_label.grid() 746 747 def clear_error(self): 748 self.error_label.grid_remove() 749 750 751def scrollbar_style(orientation): 752 # In mac ttk.Scrollbar uses native rendering unless style attribute is set 753 # see http://wiki.tcl.tk/44444#pagetoc50f90d9a 754 # Native rendering doesn't look good in dark themes 755 if running_on_mac_os() and get_workbench().uses_dark_ui_theme(): 756 return orientation + ".TScrollbar" 757 else: 758 return None 759 760 761def sequence_to_accelerator(sequence): 762 """Translates Tk event sequence to customary shortcut string 763 for showing in the menu""" 764 765 if not sequence: 766 return "" 767 768 if not sequence.startswith("<"): 769 return sequence 770 771 accelerator = ( 772 sequence.strip("<>").replace("Key-", "").replace("KeyPress-", "").replace("Control", "Ctrl") 773 ) 774 775 # Tweaking individual parts 776 parts = accelerator.split("-") 777 # tkinter shows shift with capital letter, but in shortcuts it's customary to include it explicitly 778 if len(parts[-1]) == 1 and parts[-1].isupper() and not "Shift" in parts: 779 parts.insert(-1, "Shift") 780 781 # even when shift is not required, it's customary to show shortcut with capital letter 782 if len(parts[-1]) == 1: 783 parts[-1] = parts[-1].upper() 784 785 accelerator = "+".join(parts) 786 787 # Post processing 788 accelerator = ( 789 accelerator.replace("Minus", "-") 790 .replace("minus", "-") 791 .replace("Plus", "+") 792 .replace("plus", "+") 793 ) 794 795 return accelerator 796 797 798def get_zoomed(toplevel): 799 if "-zoomed" in toplevel.wm_attributes(): # Linux 800 return bool(toplevel.wm_attributes("-zoomed")) 801 else: # Win/Mac 802 return toplevel.wm_state() == "zoomed" 803 804 805def set_zoomed(toplevel, value): 806 if "-zoomed" in toplevel.wm_attributes(): # Linux 807 toplevel.wm_attributes("-zoomed", str(int(value))) 808 else: # Win/Mac 809 if value: 810 toplevel.wm_state("zoomed") 811 else: 812 toplevel.wm_state("normal") 813 814 815class EnhancedTextWithLogging(tktextext.EnhancedText): 816 def __init__(self, master=None, style="Text", tag_current_line=False, cnf={}, **kw): 817 super().__init__( 818 master=master, style=style, tag_current_line=tag_current_line, cnf=cnf, **kw 819 ) 820 821 self._last_event_changed_line_count = False 822 823 def direct_insert(self, index, chars, tags=None, **kw): 824 # try removing line numbers 825 # TODO: shouldn't it take place only on paste? 826 # TODO: does it occur when opening a file with line numbers in it? 827 # if self._propose_remove_line_numbers and isinstance(chars, str): 828 # chars = try_remove_linenumbers(chars, self) 829 830 concrete_index = self.index(index) 831 line_before = self.get(concrete_index + " linestart", concrete_index + " lineend") 832 self._last_event_changed_line_count = "\n" in chars 833 result = tktextext.EnhancedText.direct_insert(self, index, chars, tags=tags, **kw) 834 line_after = self.get(concrete_index + " linestart", concrete_index + " lineend") 835 trivial_for_coloring, trivial_for_parens = self._is_trivial_edit( 836 chars, line_before, line_after 837 ) 838 get_workbench().event_generate( 839 "TextInsert", 840 index=concrete_index, 841 text=chars, 842 tags=tags, 843 text_widget=self, 844 trivial_for_coloring=trivial_for_coloring, 845 trivial_for_parens=trivial_for_parens, 846 ) 847 return result 848 849 def direct_delete(self, index1, index2=None, **kw): 850 try: 851 # index1 may be eg "sel.first" and it doesn't make sense *after* deletion 852 concrete_index1 = self.index(index1) 853 if index2 is not None: 854 concrete_index2 = self.index(index2) 855 else: 856 concrete_index2 = None 857 858 chars = self.get(index1, index2) 859 self._last_event_changed_line_count = "\n" in chars 860 line_before = self.get( 861 concrete_index1 + " linestart", 862 (concrete_index1 if concrete_index2 is None else concrete_index2) + " lineend", 863 ) 864 return tktextext.EnhancedText.direct_delete(self, index1, index2=index2, **kw) 865 finally: 866 line_after = self.get( 867 concrete_index1 + " linestart", 868 (concrete_index1 if concrete_index2 is None else concrete_index2) + " lineend", 869 ) 870 trivial_for_coloring, trivial_for_parens = self._is_trivial_edit( 871 chars, line_before, line_after 872 ) 873 get_workbench().event_generate( 874 "TextDelete", 875 index1=concrete_index1, 876 index2=concrete_index2, 877 text_widget=self, 878 trivial_for_coloring=trivial_for_coloring, 879 trivial_for_parens=trivial_for_parens, 880 ) 881 882 def _is_trivial_edit(self, chars, line_before, line_after): 883 # line is taken after edit for insertion and before edit for deletion 884 if not chars.strip(): 885 # linebreaks, including with automatic indent 886 # check it doesn't break a triple-quote 887 trivial_for_coloring = line_before.count("'''") == line_after.count( 888 "'''" 889 ) and line_before.count('"""') == line_after.count('"""') 890 trivial_for_parens = trivial_for_coloring 891 elif len(chars) > 1: 892 # paste, cut, load or something like this 893 trivial_for_coloring = False 894 trivial_for_parens = False 895 elif chars == "#": 896 trivial_for_coloring = "''''" not in line_before and '"""' not in line_before 897 trivial_for_parens = trivial_for_coloring and not re.search(PARENS_REGEX, line_before) 898 elif chars in "()[]{}": 899 trivial_for_coloring = line_before.count("'''") == line_after.count( 900 "'''" 901 ) and line_before.count('"""') == line_after.count('"""') 902 trivial_for_parens = False 903 elif chars == "'": 904 trivial_for_coloring = "'''" not in line_before and "'''" not in line_after 905 trivial_for_parens = False # can put parens into open string 906 elif chars == '"': 907 trivial_for_coloring = '"""' not in line_before and '"""' not in line_after 908 trivial_for_parens = False # can put parens into open string 909 elif chars == "\\": 910 # can shorten closing quote 911 trivial_for_coloring = '"""' not in line_before and '"""' not in line_after 912 trivial_for_parens = False 913 else: 914 trivial_for_coloring = line_before.count("'''") == line_after.count( 915 "'''" 916 ) and line_before.count('"""') == line_after.count('"""') 917 trivial_for_parens = trivial_for_coloring 918 919 return trivial_for_coloring, trivial_for_parens 920 921 922class SafeScrollbar(ttk.Scrollbar): 923 def __init__(self, master=None, **kw): 924 super().__init__(master=master, **kw) 925 926 def set(self, first, last): 927 try: 928 ttk.Scrollbar.set(self, first, last) 929 except Exception: 930 traceback.print_exc() 931 932 933class AutoScrollbar(SafeScrollbar): 934 # http://effbot.org/zone/tkinter-autoscrollbar.htm 935 # a vert_scrollbar that hides itself if it's not needed. only 936 # works if you use the grid geometry manager. 937 938 def __init__(self, master=None, **kw): 939 super().__init__(master=master, **kw) 940 941 def set(self, first, last): 942 if float(first) <= 0.0 and float(last) >= 1.0: 943 self.grid_remove() 944 elif float(first) > 0.001 or float(last) < 0.009: 945 # with >0 and <1 it occasionally made scrollbar wobble back and forth 946 self.grid() 947 ttk.Scrollbar.set(self, first, last) 948 949 def pack(self, **kw): 950 raise tk.TclError("cannot use pack with this widget") 951 952 def place(self, **kw): 953 raise tk.TclError("cannot use place with this widget") 954 955 956def update_entry_text(entry, text): 957 original_state = entry.cget("state") 958 entry.config(state="normal") 959 entry.delete(0, "end") 960 entry.insert(0, text) 961 entry.config(state=original_state) 962 963 964class VerticallyScrollableFrame(ttk.Frame): 965 # http://tkinter.unpythonic.net/wiki/VerticalScrolledFrame 966 967 def __init__(self, master): 968 ttk.Frame.__init__(self, master) 969 970 # set up scrolling with canvas 971 vscrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL) 972 self.canvas = tk.Canvas(self, bd=0, highlightthickness=0, yscrollcommand=vscrollbar.set) 973 vscrollbar.config(command=self.canvas.yview) 974 self.canvas.xview_moveto(0) 975 self.canvas.yview_moveto(0) 976 self.canvas.grid(row=0, column=0, sticky=tk.NSEW) 977 vscrollbar.grid(row=0, column=1, sticky=tk.NSEW) 978 self.columnconfigure(0, weight=1) 979 self.rowconfigure(0, weight=1) 980 981 self.interior = ttk.Frame(self.canvas) 982 self.interior_id = self.canvas.create_window(0, 0, window=self.interior, anchor=tk.NW) 983 self.bind("<Configure>", self._configure_interior, "+") 984 self.bind("<Expose>", self._expose, "+") 985 986 def _expose(self, event): 987 self.update_idletasks() 988 self.update_scrollbars() 989 990 def _configure_interior(self, event): 991 self.update_scrollbars() 992 993 def update_scrollbars(self): 994 # update the scrollbars to match the size of the inner frame 995 size = (self.canvas.winfo_width(), self.interior.winfo_reqheight()) 996 self.canvas.config(scrollregion="0 0 %s %s" % size) 997 if ( 998 self.interior.winfo_reqwidth() != self.canvas.winfo_width() 999 and self.canvas.winfo_width() > 10 1000 ): 1001 # update the interior's width to fit canvas 1002 # print("CAWI", self.canvas.winfo_width()) 1003 self.canvas.itemconfigure(self.interior_id, width=self.canvas.winfo_width()) 1004 1005 1006class ScrollableFrame(ttk.Frame): 1007 # http://tkinter.unpythonic.net/wiki/VerticalScrolledFrame 1008 1009 def __init__(self, master): 1010 ttk.Frame.__init__(self, master) 1011 1012 # set up scrolling with canvas 1013 vscrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL) 1014 hscrollbar = ttk.Scrollbar(self, orient=tk.HORIZONTAL) 1015 self.canvas = tk.Canvas(self, bd=0, highlightthickness=0, yscrollcommand=vscrollbar.set) 1016 vscrollbar.config(command=self.canvas.yview) 1017 hscrollbar.config(command=self.canvas.xview) 1018 1019 self.canvas.xview_moveto(0) 1020 self.canvas.yview_moveto(0) 1021 1022 self.canvas.grid(row=0, column=0, sticky=tk.NSEW) 1023 vscrollbar.grid(row=0, column=1, sticky=tk.NSEW) 1024 hscrollbar.grid(row=1, column=0, sticky=tk.NSEW) 1025 1026 self.columnconfigure(0, weight=1) 1027 self.rowconfigure(0, weight=1) 1028 1029 self.interior = ttk.Frame(self.canvas) 1030 self.interior.columnconfigure(0, weight=1) 1031 self.interior.rowconfigure(0, weight=1) 1032 self.interior_id = self.canvas.create_window(0, 0, window=self.interior, anchor=tk.NW) 1033 self.bind("<Configure>", self._configure_interior, "+") 1034 self.bind("<Expose>", self._expose, "+") 1035 1036 def _expose(self, event): 1037 self.update_idletasks() 1038 self._configure_interior(event) 1039 1040 def _configure_interior(self, event): 1041 # update the scrollbars to match the size of the inner frame 1042 size = (self.canvas.winfo_reqwidth(), self.interior.winfo_reqheight()) 1043 self.canvas.config(scrollregion="0 0 %s %s" % size) 1044 1045 1046class ThemedListbox(tk.Listbox): 1047 def __init__(self, master=None, cnf={}, **kw): 1048 super().__init__(master=master, cnf=cnf, **kw) 1049 1050 self._ui_theme_change_binding = self.bind( 1051 "<<ThemeChanged>>", self._reload_theme_options, True 1052 ) 1053 self._reload_theme_options() 1054 1055 def _reload_theme_options(self, event=None): 1056 style = ttk.Style() 1057 1058 states = [] 1059 if self["state"] == "disabled": 1060 states.append("disabled") 1061 1062 # Following crashes when a combobox is focused 1063 # if self.focus_get() == self: 1064 # states.append("focus") 1065 opts = {} 1066 for key in [ 1067 "background", 1068 "foreground", 1069 "highlightthickness", 1070 "highlightcolor", 1071 "highlightbackground", 1072 ]: 1073 value = style.lookup(self.get_style_name(), key, states) 1074 if value: 1075 opts[key] = value 1076 1077 self.configure(opts) 1078 1079 def get_style_name(self): 1080 return "Listbox" 1081 1082 def destroy(self): 1083 self.unbind("<<ThemeChanged>>", self._ui_theme_change_binding) 1084 super().destroy() 1085 1086 1087class ToolTip: 1088 """Taken from http://www.voidspace.org.uk/python/weblog/arch_d7_2006_07_01.shtml""" 1089 1090 def __init__(self, widget, options): 1091 self.widget = widget 1092 self.tipwindow = None 1093 self.id = None 1094 self.x = self.y = 0 1095 self.options = options 1096 1097 def showtip(self, text): 1098 "Display text in tooltip window" 1099 self.text = text 1100 if self.tipwindow or not self.text: 1101 return 1102 x, y, _, cy = self.widget.bbox("insert") 1103 x = x + self.widget.winfo_rootx() + 27 1104 y = y + cy + self.widget.winfo_rooty() + self.widget.winfo_height() + 2 1105 self.tipwindow = tw = tk.Toplevel(self.widget) 1106 if running_on_mac_os(): 1107 try: 1108 # Must be the first thing to do after creating window 1109 # https://wiki.tcl-lang.org/page/MacWindowStyle 1110 tw.tk.call( 1111 "::tk::unsupported::MacWindowStyle", "style", tw._w, "help", "noActivates" 1112 ) 1113 if get_tk_version_info() >= (8, 6, 10) and running_on_mac_os(): 1114 tw.wm_overrideredirect(1) 1115 except tk.TclError: 1116 pass 1117 else: 1118 tw.wm_overrideredirect(1) 1119 1120 tw.wm_geometry("+%d+%d" % (x, y)) 1121 1122 if running_on_mac_os(): 1123 # TODO: maybe it's because of Tk 8.5, not because of Mac 1124 tw.wm_transient(self.widget) 1125 1126 label = tk.Label(tw, text=self.text, **self.options) 1127 label.pack() 1128 # get_workbench().bind("WindowFocusOut", self.hidetip, True) 1129 1130 def hidetip(self, event=None): 1131 tw = self.tipwindow 1132 self.tipwindow = None 1133 if tw: 1134 tw.destroy() 1135 1136 # get_workbench().unbind("WindowFocusOut", self.hidetip) 1137 1138 1139def create_tooltip(widget, text, **kw): 1140 options = get_style_configuration("Tooltip").copy() 1141 options.setdefault("background", "#ffffe0") 1142 options.setdefault("foreground", "#000000") 1143 options.setdefault("relief", "solid") 1144 options.setdefault("borderwidth", 1) 1145 options.setdefault("padx", 1) 1146 options.setdefault("pady", 0) 1147 options.update(kw) 1148 1149 toolTip = ToolTip(widget, options) 1150 1151 def enter(event): 1152 toolTip.showtip(text) 1153 1154 def leave(event): 1155 toolTip.hidetip() 1156 1157 widget.bind("<Enter>", enter) 1158 widget.bind("<Leave>", leave) 1159 1160 1161class NoteBox(CommonDialog): 1162 def __init__(self, master=None, max_default_width=300, **kw): 1163 super().__init__(master=master, highlightthickness=0, **kw) 1164 1165 self._max_default_width = max_default_width 1166 1167 self.wm_overrideredirect(True) 1168 if running_on_mac_os(): 1169 # TODO: maybe it's because of Tk 8.5, not because of Mac 1170 self.wm_transient(master) 1171 try: 1172 # For Mac OS 1173 self.tk.call( 1174 "::tk::unsupported::MacWindowStyle", "style", self._w, "help", "noActivates" 1175 ) 1176 except tk.TclError: 1177 pass 1178 1179 self._current_chars = "" 1180 self._click_bindings = {} 1181 1182 self.padx = 5 1183 self.pady = 5 1184 self.text = TweakableText( 1185 self, 1186 background="#ffffe0", 1187 borderwidth=1, 1188 relief="solid", 1189 undo=False, 1190 read_only=True, 1191 font="TkDefaultFont", 1192 highlightthickness=0, 1193 padx=self.padx, 1194 pady=self.pady, 1195 wrap="word", 1196 ) 1197 1198 self.text.grid(row=0, column=0, sticky="nsew") 1199 1200 self.columnconfigure(0, weight=1) 1201 self.rowconfigure(0, weight=1) 1202 self.text.bind("<Escape>", self.close, True) 1203 1204 # tk._default_root.bind_all("<1>", self._close_maybe, True) 1205 # tk._default_root.bind_all("<Key>", self.close, True) 1206 1207 self.withdraw() 1208 1209 def clear(self): 1210 for tag in self._click_bindings: 1211 self.text.tag_unbind(tag, "<1>", self._click_bindings[tag]) 1212 self.text.tag_remove(tag, "1.0", "end") 1213 1214 self.text.direct_delete("1.0", "end") 1215 self._current_chars = "" 1216 self._click_bindings.clear() 1217 1218 def set_content(self, *items): 1219 self.clear() 1220 1221 for item in items: 1222 if isinstance(item, str): 1223 self.text.direct_insert("1.0", item) 1224 self._current_chars = item 1225 else: 1226 assert isinstance(item, (list, tuple)) 1227 chars, *props = item 1228 if len(props) > 0 and callable(props[-1]): 1229 tags = tuple(props[:-1]) 1230 click_handler = props[-1] 1231 else: 1232 tags = tuple(props) 1233 click_handler = None 1234 1235 self.append_text(chars, tags, click_handler) 1236 1237 self.text.see("1.0") 1238 1239 def append_text(self, chars, tags=(), click_handler=None): 1240 tags = tuple(tags) 1241 1242 if click_handler is not None: 1243 click_tag = "click_%d" % len(self._click_bindings) 1244 tags = tags + (click_tag,) 1245 binding = self.text.tag_bind(click_tag, "<1>", click_handler, True) 1246 self._click_bindings[click_tag] = binding 1247 1248 self.text.direct_insert("end", chars, tags) 1249 self._current_chars += chars 1250 1251 def place(self, target, focus=None): 1252 1253 # Compute the area that will be described by this Note 1254 focus_x = target.winfo_rootx() 1255 focus_y = target.winfo_rooty() 1256 focus_height = target.winfo_height() 1257 1258 if isinstance(focus, TextRange): 1259 assert isinstance(target, tk.Text) 1260 topleft = target.bbox("%d.%d" % (focus.lineno, focus.col_offset)) 1261 if focus.end_col_offset == 0: 1262 botright = target.bbox( 1263 "%d.%d lineend" % (focus.end_lineno - 1, focus.end_lineno - 1) 1264 ) 1265 else: 1266 botright = target.bbox("%d.%d" % (focus.end_lineno, focus.end_col_offset)) 1267 1268 if topleft and botright: 1269 focus_x += topleft[0] 1270 focus_y += topleft[1] 1271 focus_height = botright[1] - topleft[1] + botright[3] 1272 1273 elif isinstance(focus, (list, tuple)): 1274 focus_x += focus[0] 1275 focus_y += focus[1] 1276 focus_height = focus[3] 1277 1278 elif focus is None: 1279 pass 1280 1281 else: 1282 raise TypeError("Unsupported focus") 1283 1284 # Compute dimensions of the note 1285 font = self.text["font"] 1286 if isinstance(font, str): 1287 font = tk.font.nametofont(font) 1288 1289 lines = self._current_chars.splitlines() 1290 max_line_width = 0 1291 for line in lines: 1292 max_line_width = max(max_line_width, font.measure(line)) 1293 1294 width = min(max_line_width, self._max_default_width) + self.padx * 2 + 2 1295 self.wm_geometry("%dx%d+%d+%d" % (width, 100, focus_x, focus_y + focus_height)) 1296 1297 self.update_idletasks() 1298 line_count = int(float(self.text.index("end"))) 1299 line_height = font.metrics()["linespace"] 1300 1301 self.wm_geometry( 1302 "%dx%d+%d+%d" % (width, line_count * line_height, focus_x, focus_y + focus_height) 1303 ) 1304 1305 # TODO: detect the situation when note doesn't fit under 1306 # the focus box and should be placed above 1307 1308 self.deiconify() 1309 1310 def show_note(self, *content_items: Union[str, List], target=None, focus=None) -> None: 1311 1312 self.set_content(*content_items) 1313 self.place(target, focus) 1314 1315 def _close_maybe(self, event): 1316 if event.widget not in [self, self.text]: 1317 self.close(event) 1318 1319 def close(self, event=None): 1320 self.withdraw() 1321 1322 1323def get_widget_offset_from_toplevel(widget): 1324 x = 0 1325 y = 0 1326 toplevel = widget.winfo_toplevel() 1327 while widget != toplevel: 1328 x += widget.winfo_x() 1329 y += widget.winfo_y() 1330 widget = widget.master 1331 return x, y 1332 1333 1334class EnhancedVar(tk.Variable): 1335 def __init__(self, master=None, value=None, name=None, modification_listener=None): 1336 if master is not None and not isinstance(master, (tk.Widget, tk.Wm)): 1337 raise TypeError("First positional argument 'master' must be None, Widget or Wm") 1338 1339 super().__init__(master=master, value=value, name=name) 1340 self.modified = False 1341 self.modification_listener = modification_listener 1342 if sys.version_info < (3, 6): 1343 self.trace("w", self._on_write) 1344 else: 1345 self.trace_add("write", self._on_write) 1346 1347 def _on_write(self, *args): 1348 self.modified = True 1349 if self.modification_listener: 1350 try: 1351 self.modification_listener() 1352 except Exception: 1353 # Otherwise whole process will be brought down 1354 # because for some reason Tk tries to call non-existing method 1355 # on variable 1356 get_workbench().report_exception() 1357 1358 1359class EnhancedStringVar(EnhancedVar, tk.StringVar): 1360 pass 1361 1362 1363class EnhancedIntVar(EnhancedVar, tk.IntVar): 1364 pass 1365 1366 1367class EnhancedBooleanVar(EnhancedVar, tk.BooleanVar): 1368 pass 1369 1370 1371class EnhancedDoubleVar(EnhancedVar, tk.DoubleVar): 1372 pass 1373 1374 1375def create_string_var(value, modification_listener=None) -> EnhancedStringVar: 1376 """Creates a tk.StringVar with "modified" attribute 1377 showing whether the variable has been modified after creation""" 1378 return EnhancedStringVar(None, value, None, modification_listener) 1379 1380 1381def create_int_var(value, modification_listener=None) -> EnhancedIntVar: 1382 """See create_string_var""" 1383 return EnhancedIntVar(None, value, None, modification_listener) 1384 1385 1386def create_double_var(value, modification_listener=None) -> EnhancedDoubleVar: 1387 """See create_string_var""" 1388 return EnhancedDoubleVar(None, value, None, modification_listener) 1389 1390 1391def create_boolean_var(value, modification_listener=None) -> EnhancedBooleanVar: 1392 """See create_string_var""" 1393 return EnhancedBooleanVar(None, value, None, modification_listener) 1394 1395 1396def shift_is_pressed(event_state): 1397 # http://infohost.nmt.edu/tcc/help/pubs/tkinter/web/event-handlers.html 1398 # http://stackoverflow.com/q/32426250/261181 1399 return event_state & 0x0001 1400 1401 1402def caps_lock_is_on(event_state): 1403 # http://infohost.nmt.edu/tcc/help/pubs/tkinter/web/event-handlers.html 1404 # http://stackoverflow.com/q/32426250/261181 1405 return event_state & 0x0002 1406 1407 1408def control_is_pressed(event_state): 1409 # http://infohost.nmt.edu/tcc/help/pubs/tkinter/web/event-handlers.html 1410 # http://stackoverflow.com/q/32426250/261181 1411 return event_state & 0x0004 1412 1413 1414def sequence_to_event_state_and_keycode(sequence: str) -> Optional[Tuple[int, int]]: 1415 # remember handlers for certain shortcuts which require 1416 # different treatment on non-latin keyboards 1417 if sequence[0] != "<": 1418 return None 1419 1420 parts = sequence.strip("<").strip(">").split("-") 1421 # support only latin letters for now 1422 if parts[-1].lower() not in list("abcdefghijklmnopqrstuvwxyz"): 1423 return None 1424 1425 letter = parts.pop(-1) 1426 if "Key" in parts: 1427 parts.remove("Key") 1428 if "key" in parts: 1429 parts.remove("key") 1430 1431 modifiers = {part.lower() for part in parts} 1432 1433 if letter.isupper(): 1434 modifiers.add("shift") 1435 1436 if modifiers not in [{"control"}, {"control", "shift"}]: 1437 # don't support others for now 1438 return None 1439 1440 event_state = 0 1441 # http://infohost.nmt.edu/tcc/help/pubs/tkinter/web/event-handlers.html 1442 # https://stackoverflow.com/questions/32426250/python-documentation-and-or-lack-thereof-e-g-keyboard-event-state 1443 for modifier in modifiers: 1444 if modifier == "shift": 1445 event_state |= 0x0001 1446 elif modifier == "control": 1447 event_state |= 0x0004 1448 else: 1449 # unsupported modifier 1450 return None 1451 1452 # for latin letters keycode is same as its ascii code 1453 return (event_state, ord(letter.upper())) 1454 1455 1456def select_sequence(win_version, mac_version, linux_version=None): 1457 if running_on_windows(): 1458 return win_version 1459 elif running_on_mac_os(): 1460 return mac_version 1461 elif running_on_linux() and linux_version: 1462 return linux_version 1463 else: 1464 return win_version 1465 1466 1467def try_remove_linenumbers(text, master): 1468 try: 1469 if has_line_numbers(text) and messagebox.askyesno( 1470 title="Remove linenumbers", 1471 message="Do you want to remove linenumbers from pasted text?", 1472 default=messagebox.YES, 1473 master=master, 1474 ): 1475 return remove_line_numbers(text) 1476 else: 1477 return text 1478 except Exception: 1479 traceback.print_exc() 1480 return text 1481 1482 1483def has_line_numbers(text): 1484 lines = text.splitlines() 1485 return len(lines) > 2 and all([len(split_after_line_number(line)) == 2 for line in lines]) 1486 1487 1488def split_after_line_number(s): 1489 parts = re.split(r"(^\s*\d+\.?)", s) 1490 if len(parts) == 1: 1491 return parts 1492 else: 1493 assert len(parts) == 3 and parts[0] == "" 1494 return parts[1:] 1495 1496 1497def remove_line_numbers(s): 1498 cleaned_lines = [] 1499 for line in s.splitlines(): 1500 parts = split_after_line_number(line) 1501 if len(parts) != 2: 1502 return s 1503 else: 1504 cleaned_lines.append(parts[1]) 1505 1506 return textwrap.dedent(("\n".join(cleaned_lines)) + "\n") 1507 1508 1509def center_window(win, master=None): 1510 # for backward compat 1511 return assign_geometry(win, master) 1512 1513 1514def assign_geometry(win, master=None, min_left=0, min_top=0): 1515 if master is None: 1516 master = tk._default_root 1517 1518 size = get_workbench().get_option(get_size_option_name(win)) 1519 if size: 1520 width, height = size 1521 saved_size = True 1522 else: 1523 fallback_width = 600 1524 fallback_height = 400 1525 # need to wait until size is computed 1526 # (unfortunately this causes dialog to jump) 1527 if getattr(master, "initializing", False): 1528 # can't get reliable positions when main window is not in mainloop yet 1529 width = fallback_width 1530 height = fallback_height 1531 else: 1532 if not running_on_linux(): 1533 # better to avoid in Linux because it causes ugly jump 1534 win.update_idletasks() 1535 # looks like it doesn't take window border into account 1536 width = win.winfo_width() 1537 height = win.winfo_height() 1538 1539 if width < 10: 1540 # ie. size measurement is not correct 1541 width = fallback_width 1542 height = fallback_height 1543 1544 saved_size = False 1545 1546 left = master.winfo_rootx() + master.winfo_width() // 2 - width // 2 1547 top = master.winfo_rooty() + master.winfo_height() // 2 - height // 2 1548 1549 left = max(left, min_left) 1550 top = max(top, min_top) 1551 1552 if saved_size: 1553 win.geometry("%dx%d+%d+%d" % (width, height, left, top)) 1554 else: 1555 win.geometry("+%d+%d" % (left, top)) 1556 1557 1558class WaitingDialog(CommonDialog): 1559 def __init__(self, master, async_result, description, title="Please wait!", timeout=None): 1560 self._async_result = async_result 1561 super().__init__(master) 1562 if misc_utils.running_on_mac_os(): 1563 self.configure(background="systemSheetBackground") 1564 self.title(title) 1565 self.resizable(height=tk.FALSE, width=tk.FALSE) 1566 # self.protocol("WM_DELETE_WINDOW", self._close) 1567 self.desc_label = ttk.Label(self, text=description, wraplength=300) 1568 self.desc_label.grid(padx=20, pady=20) 1569 1570 self.update_idletasks() 1571 1572 self.timeout = timeout 1573 self.start_time = time.time() 1574 self.after(500, self._poll) 1575 1576 def _poll(self): 1577 if self._async_result.ready(): 1578 self._close() 1579 elif self.timeout and time.time() - self.start_time > self.timeout: 1580 raise TimeoutError() 1581 else: 1582 self.after(500, self._poll) 1583 self.desc_label["text"] = self.desc_label["text"] + "." 1584 1585 def _close(self): 1586 self.destroy() 1587 1588 1589def run_with_waiting_dialog(master, action, args=(), description="Working"): 1590 # http://stackoverflow.com/a/14299004/261181 1591 from multiprocessing.pool import ThreadPool 1592 1593 pool = ThreadPool(processes=1) 1594 1595 async_result = pool.apply_async(action, args) 1596 dlg = WaitingDialog(master, async_result, description=description) 1597 show_dialog(dlg, master) 1598 1599 return async_result.get() 1600 1601 1602class FileCopyDialog(CommonDialog): 1603 def __init__(self, master, source, destination, description=None, fsync=True): 1604 self._source = source 1605 self._destination = destination 1606 self._old_bytes_copied = 0 1607 self._bytes_copied = 0 1608 self._fsync = fsync 1609 self._done = False 1610 self._cancelled = False 1611 self._closed = False 1612 1613 super().__init__(master) 1614 1615 main_frame = ttk.Frame(self) # To get styled background 1616 main_frame.grid(row=0, column=0, sticky="nsew") 1617 self.rowconfigure(0, weight=1) 1618 self.columnconfigure(0, weight=1) 1619 1620 self.title(tr("Copying")) 1621 1622 if description is None: 1623 description = tr("Copying\n %s\nto\n %s") % (source, destination) 1624 1625 label = ttk.Label(main_frame, text=description) 1626 label.grid(row=0, column=0, columnspan=2, sticky="nw", padx=15, pady=15) 1627 1628 self._bar = ttk.Progressbar(main_frame, maximum=os.path.getsize(source), length=200) 1629 self._bar.grid(row=1, column=0, columnspan=2, sticky="nsew", padx=15, pady=0) 1630 1631 self._cancel_button = ttk.Button(main_frame, text=tr("Cancel"), command=self._cancel) 1632 self._cancel_button.grid(row=2, column=1, sticky="ne", padx=15, pady=15) 1633 self._bar.focus_set() 1634 1635 main_frame.columnconfigure(0, weight=1) 1636 1637 self._update_progress() 1638 1639 self.bind("<Escape>", self._cancel, True) # escape-close only if process has completed 1640 self.protocol("WM_DELETE_WINDOW", self._cancel) 1641 self._start() 1642 1643 def _start(self): 1644 def work(): 1645 self._copy_progess = 0 1646 1647 with open(self._source, "rb") as fsrc: 1648 with open(self._destination, "wb") as fdst: 1649 while True: 1650 buf = fsrc.read(16 * 1024) 1651 if not buf: 1652 break 1653 1654 fdst.write(buf) 1655 fdst.flush() 1656 if self._fsync: 1657 os.fsync(fdst) 1658 self._bytes_copied += len(buf) 1659 1660 self._done = True 1661 1662 threading.Thread(target=work, daemon=True).start() 1663 1664 def _update_progress(self): 1665 if self._done: 1666 if not self._closed: 1667 self._close() 1668 return 1669 1670 self._bar.step(self._bytes_copied - self._old_bytes_copied) 1671 self._old_bytes_copied = self._bytes_copied 1672 1673 self.after(100, self._update_progress) 1674 1675 def _close(self): 1676 self.destroy() 1677 self._closed = True 1678 1679 def _cancel(self, event=None): 1680 self._cancelled = True 1681 self._close() 1682 1683 1684class ChoiceDialog(CommonDialogEx): 1685 def __init__( 1686 self, 1687 master=None, 1688 title="Choose one", 1689 question: str = "Choose one:", 1690 choices=[], 1691 initial_choice_index=None, 1692 ) -> None: 1693 super().__init__(master=master) 1694 1695 self.title(title) 1696 self.resizable(False, False) 1697 1698 self.main_frame.columnconfigure(0, weight=1) 1699 1700 row = 0 1701 question_label = ttk.Label(self.main_frame, text=question) 1702 question_label.grid(row=row, column=0, columnspan=2, sticky="w", padx=20, pady=20) 1703 row += 1 1704 1705 self.var = tk.StringVar("") 1706 if initial_choice_index is not None: 1707 self.var.set(choices[initial_choice_index]) 1708 for choice in choices: 1709 rb = ttk.Radiobutton(self.main_frame, text=choice, variable=self.var, value=choice) 1710 rb.grid(row=row, column=0, columnspan=2, sticky="w", padx=20) 1711 row += 1 1712 1713 ok_button = ttk.Button(self.main_frame, text=tr("OK"), command=self._ok, default="active") 1714 ok_button.grid(row=row, column=0, sticky="e", pady=20) 1715 1716 cancel_button = ttk.Button(self.main_frame, text=tr("Cancel"), command=self._cancel) 1717 cancel_button.grid(row=row, column=1, sticky="e", padx=20, pady=20) 1718 1719 self.bind("<Escape>", self._cancel, True) 1720 self.bind("<Return>", self._ok, True) 1721 self.protocol("WM_DELETE_WINDOW", self._cancel) 1722 1723 def _ok(self): 1724 self.result = self.var.get() 1725 if not self.result: 1726 self.result = None 1727 1728 self.destroy() 1729 1730 def _cancel(self): 1731 self.result = None 1732 self.destroy() 1733 1734 1735class LongTextDialog(CommonDialog): 1736 def __init__(self, title, text_content, parent=None): 1737 if parent is None: 1738 parent = tk._default_root 1739 1740 super().__init__(master=parent) 1741 self.title(title) 1742 1743 main_frame = ttk.Frame(self) 1744 main_frame.grid(row=0, column=0, sticky="nsew") 1745 self.columnconfigure(0, weight=1) 1746 self.rowconfigure(0, weight=1) 1747 1748 default_font = tk.font.nametofont("TkDefaultFont") 1749 self._text = tktextext.TextFrame( 1750 main_frame, 1751 read_only=True, 1752 wrap="none", 1753 font=default_font, 1754 width=80, 1755 height=10, 1756 relief="sunken", 1757 borderwidth=1, 1758 ) 1759 self._text.grid(row=1, column=0, columnspan=2, sticky="nsew", padx=20, pady=20) 1760 self._text.text.direct_insert("1.0", text_content) 1761 self._text.text.see("1.0") 1762 1763 copy_button = ttk.Button( 1764 main_frame, command=self._copy, text=tr("Copy to clipboard"), width=20 1765 ) 1766 copy_button.grid(row=2, column=0, sticky="w", padx=20, pady=(0, 20)) 1767 1768 close_button = ttk.Button( 1769 main_frame, command=self._close, text=tr("Close"), default="active" 1770 ) 1771 close_button.grid(row=2, column=1, sticky="w", padx=20, pady=(0, 20)) 1772 close_button.focus_set() 1773 1774 main_frame.columnconfigure(0, weight=1) 1775 main_frame.rowconfigure(1, weight=1) 1776 1777 self.protocol("WM_DELETE_WINDOW", self._close) 1778 self.bind("<Escape>", self._close, True) 1779 1780 def _copy(self, event=None): 1781 self.clipboard_clear() 1782 self.clipboard_append(self._text.text.get("1.0", "end")) 1783 1784 def _close(self, event=None): 1785 self.destroy() 1786 1787 1788def ask_one_from_choices( 1789 master=None, 1790 title="Choose one", 1791 question: str = "Choose one:", 1792 choices=[], 1793 initial_choice_index=None, 1794): 1795 dlg = ChoiceDialog(master, title, question, choices, initial_choice_index) 1796 show_dialog(dlg, master) 1797 return dlg.result 1798 1799 1800def get_busy_cursor(): 1801 if running_on_windows(): 1802 return "wait" 1803 elif running_on_mac_os(): 1804 return "spinning" 1805 else: 1806 return "watch" 1807 1808 1809def get_tk_version_str(): 1810 return tk._default_root.tk.call("info", "patchlevel") 1811 1812 1813def get_tk_version_info(): 1814 result = [] 1815 for part in get_tk_version_str().split("."): 1816 try: 1817 result.append(int(part)) 1818 except Exception: 1819 result.append(0) 1820 return tuple(result) 1821 1822 1823def get_style_configuration(style_name, default={}): 1824 style = ttk.Style() 1825 # NB! style.configure seems to reuse the returned dict 1826 # Don't change it without copying first 1827 result = style.configure(style_name) 1828 if result is None: 1829 return default 1830 else: 1831 return result 1832 1833 1834def lookup_style_option(style_name, option_name, default=None): 1835 style = ttk.Style() 1836 setting = style.lookup(style_name, option_name) 1837 if setting in [None, ""]: 1838 return default 1839 elif setting == "True": 1840 return True 1841 elif setting == "False": 1842 return False 1843 else: 1844 return setting 1845 1846 1847def scale(value): 1848 return get_workbench().scale(value) 1849 1850 1851def open_path_in_system_file_manager(path): 1852 if running_on_mac_os(): 1853 # http://stackoverflow.com/a/3520693/261181 1854 # -R doesn't allow showing hidden folders 1855 subprocess.Popen(["open", path]) 1856 elif running_on_linux(): 1857 subprocess.Popen(["xdg-open", path]) 1858 else: 1859 assert running_on_windows() 1860 subprocess.Popen(["explorer", path]) 1861 1862 1863def _get_dialog_provider(): 1864 if platform.system() != "Linux" or get_workbench().get_option("file.avoid_zenity"): 1865 return filedialog 1866 1867 import shutil 1868 1869 if shutil.which("zenity"): 1870 return _ZenityDialogProvider 1871 1872 # fallback 1873 return filedialog 1874 1875 1876def asksaveasfilename(**options): 1877 # https://tcl.tk/man/tcl8.6/TkCmd/getSaveFile.htm 1878 _check_dialog_parent(options) 1879 return _get_dialog_provider().asksaveasfilename(**options) 1880 1881 1882def askopenfilename(**options): 1883 # https://tcl.tk/man/tcl8.6/TkCmd/getOpenFile.htm 1884 _check_dialog_parent(options) 1885 return _get_dialog_provider().askopenfilename(**options) 1886 1887 1888def askopenfilenames(**options): 1889 # https://tcl.tk/man/tcl8.6/TkCmd/getOpenFile.htm 1890 _check_dialog_parent(options) 1891 return _get_dialog_provider().askopenfilenames(**options) 1892 1893 1894def askdirectory(**options): 1895 # https://tcl.tk/man/tcl8.6/TkCmd/chooseDirectory.htm 1896 _check_dialog_parent(options) 1897 return _get_dialog_provider().askdirectory(**options) 1898 1899 1900def _check_dialog_parent(options): 1901 if options.get("parent") and options.get("master"): 1902 parent = options["parent"].winfo_toplevel() 1903 master = options["master"].winfo_toplevel() 1904 if parent is not master: 1905 logger.warning( 1906 "Dialog with different parent/master toplevels:\n%s", 1907 "".join(traceback.format_stack()), 1908 ) 1909 elif options.get("parent"): 1910 parent = options["parent"].winfo_toplevel() 1911 master = options["parent"].winfo_toplevel() 1912 elif options.get("master"): 1913 parent = options["master"].winfo_toplevel() 1914 master = options["master"].winfo_toplevel() 1915 else: 1916 logger.warning("Dialog without parent:\n%s", "".join(traceback.format_stack())) 1917 parent = tk._default_root 1918 master = tk._default_root 1919 1920 options["parent"] = parent 1921 options["master"] = master 1922 1923 if running_on_mac_os(): 1924 # used to require master/parent (https://bugs.python.org/issue34927) 1925 # but this is deprecated in Catalina (https://github.com/thonny/thonny/issues/840) 1926 # TODO: Consider removing this when upgrading from Tk 8.6.8 1927 del options["master"] 1928 del options["parent"] 1929 1930 1931class _ZenityDialogProvider: 1932 # https://www.writebash.com/bash-gui/zenity-create-file-selection-dialog-224.html 1933 # http://linux.byexamples.com/archives/259/a-complete-zenity-dialog-examples-1/ 1934 # http://linux.byexamples.com/archives/265/a-complete-zenity-dialog-examples-2/ 1935 1936 # another possibility is to use PyGobject: https://github.com/poulp/zenipy 1937 1938 @classmethod 1939 def askopenfilename(cls, **options): 1940 args = cls._convert_common_options("Open file", **options) 1941 return cls._call(args) 1942 1943 @classmethod 1944 def askopenfilenames(cls, **options): 1945 args = cls._convert_common_options("Open files", **options) 1946 return cls._call(args + ["--multiple"]).split("|") 1947 1948 @classmethod 1949 def asksaveasfilename(cls, **options): 1950 args = cls._convert_common_options("Save as", **options) 1951 args.append("--save") 1952 if options.get("confirmoverwrite", True): 1953 args.append("--confirm-overwrite") 1954 1955 filename = cls._call(args) 1956 if not filename: 1957 return None 1958 1959 if "defaultextension" in options and "." not in os.path.basename(filename): 1960 filename += options["defaultextension"] 1961 1962 return filename 1963 1964 @classmethod 1965 def askdirectory(cls, **options): 1966 args = cls._convert_common_options("Select directory", **options) 1967 args.append("--directory") 1968 return cls._call(args) 1969 1970 @classmethod 1971 def _convert_common_options(cls, default_title, **options): 1972 args = ["--file-selection", "--title=%s" % options.get("title", default_title)] 1973 1974 filename = _options_to_zenity_filename(options) 1975 if filename: 1976 args.append("--filename=%s" % filename) 1977 1978 parent = options.get("parent", options.get("master", None)) 1979 if parent is not None: 1980 args.append("--modal") 1981 args.append("--attach=%s" % hex(parent.winfo_id())) 1982 1983 for desc, pattern in options.get("filetypes", ()): 1984 # zenity requires star before extension 1985 pattern = pattern.replace(" .", " *.") 1986 if pattern.startswith("."): 1987 pattern = "*" + pattern 1988 1989 if pattern == "*.*": 1990 # ".*" was provided to make the pattern safe for Tk dialog 1991 # not required with Zenity 1992 pattern = "*" 1993 1994 args.append("--file-filter=%s | %s" % (desc, pattern)) 1995 1996 return args 1997 1998 @classmethod 1999 def _call(cls, args): 2000 args = ["zenity", "--name=Thonny", "--class=Thonny"] + args 2001 result = subprocess.run( 2002 args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True 2003 ) 2004 if result.returncode == 0: 2005 return result.stdout.strip() 2006 else: 2007 # TODO: log problems 2008 print(result.stderr, file=sys.stderr) 2009 # could check stderr, but it may contain irrelevant warnings 2010 return None 2011 2012 2013def _options_to_zenity_filename(options): 2014 if options.get("initialdir"): 2015 if options.get("initialfile"): 2016 return os.path.join(options["initialdir"], options["initialfile"]) 2017 else: 2018 return options["initialdir"] + os.path.sep 2019 2020 return None 2021 2022 2023def register_latin_shortcut( 2024 registry, sequence: str, handler: Callable, tester: Optional[Callable] 2025) -> None: 2026 res = sequence_to_event_state_and_keycode(sequence) 2027 if res is not None: 2028 if res not in registry: 2029 registry[res] = [] 2030 registry[res].append((handler, tester)) 2031 2032 2033def handle_mistreated_latin_shortcuts(registry, event): 2034 # tries to handle Ctrl+LatinLetter shortcuts 2035 # given from non-Latin keyboards 2036 # See: https://bitbucket.org/plas/thonny/issues/422/edit-keyboard-shortcuts-ctrl-c-ctrl-v-etc 2037 2038 # only consider events with Control held 2039 if not event.state & 0x04: 2040 return 2041 2042 if running_on_mac_os(): 2043 return 2044 2045 # consider only part of the state, 2046 # because at least on Windows, Ctrl-shortcuts' state 2047 # has something extra 2048 simplified_state = 0x04 2049 if shift_is_pressed(event.state): 2050 simplified_state |= 0x01 2051 2052 # print(simplified_state, event.keycode) 2053 if (simplified_state, event.keycode) in registry: 2054 if event.keycode != ord(event.char) and event.keysym in (None, "??"): 2055 # keycode and char doesn't match, 2056 # this means non-latin keyboard 2057 for handler, tester in registry[(simplified_state, event.keycode)]: 2058 if tester is None or tester(): 2059 handler() 2060 2061 2062def show_dialog(dlg, master=None, geometry=True, min_left=0, min_top=0): 2063 if getattr(dlg, "closed", False): 2064 return 2065 2066 if master is None: 2067 master = getattr(dlg, "parent", None) or getattr(dlg, "master", None) or tk._default_root 2068 2069 master = master.winfo_toplevel() 2070 2071 get_workbench().event_generate("WindowFocusOut") 2072 # following order seems to give most smooth appearance 2073 focused_widget = master.focus_get() 2074 dlg.transient(master.winfo_toplevel()) 2075 2076 if geometry: 2077 # dlg.withdraw() # unfortunately inhibits size calculations in assign_geometry 2078 if isinstance(geometry, str): 2079 dlg.geometry(geometry) 2080 else: 2081 assign_geometry(dlg, master, min_left, min_top) 2082 # dlg.wm_deiconify() 2083 2084 dlg.lift() 2085 dlg.focus_set() 2086 2087 try: 2088 dlg.grab_set() 2089 except TclError as e: 2090 print("Can't grab:", e, file=sys.stderr) 2091 2092 master.winfo_toplevel().wait_window(dlg) 2093 dlg.grab_release() 2094 master.winfo_toplevel().lift() 2095 master.winfo_toplevel().focus_force() 2096 master.winfo_toplevel().grab_set() 2097 if running_on_mac_os(): 2098 master.winfo_toplevel().grab_release() 2099 2100 if focused_widget is not None: 2101 try: 2102 focused_widget.focus_force() 2103 except TclError: 2104 pass 2105 2106 2107def popen_with_ui_thread_callback(*Popen_args, on_completion, poll_delay=0.1, **Popen_kwargs): 2108 if "encoding" not in Popen_kwargs: 2109 if "env" not in Popen_kwargs: 2110 Popen_kwargs["env"] = os.environ.copy() 2111 Popen_kwargs["env"]["PYTHONIOENCODING"] = "utf-8" 2112 if sys.version_info >= (3, 6): 2113 Popen_kwargs["encoding"] = "utf-8" 2114 2115 proc = subprocess.Popen(*Popen_args, **Popen_kwargs) 2116 2117 # Need to read in thread in order to avoid blocking because 2118 # of full pipe buffer (see https://bugs.python.org/issue1256) 2119 out_lines = [] 2120 err_lines = [] 2121 2122 def read_stream(stream, target_list): 2123 while True: 2124 line = stream.readline() 2125 if line: 2126 target_list.append(line) 2127 else: 2128 break 2129 2130 t_out = threading.Thread(target=read_stream, daemon=True, args=(proc.stdout, out_lines)) 2131 t_err = threading.Thread(target=read_stream, daemon=True, args=(proc.stderr, err_lines)) 2132 t_out.start() 2133 t_err.start() 2134 2135 def poll(): 2136 if proc.poll() is not None: 2137 t_out.join(3) 2138 t_err.join(3) 2139 on_completion(proc, out_lines, err_lines) 2140 return 2141 2142 tk._default_root.after(int(poll_delay * 1000), poll) 2143 2144 poll() 2145 return proc 2146 2147 2148class MenuEx(tk.Menu): 2149 def __init__(self, target): 2150 self._testers = {} 2151 super().__init__( 2152 target, tearoff=False, postcommand=self.on_post, **get_style_configuration("Menu") 2153 ) 2154 2155 def on_post(self, *args): 2156 self.update_item_availability() 2157 2158 def update_item_availability(self): 2159 for i in range(self.index("end") + 1): 2160 item_data = self.entryconfigure(i) 2161 if "label" in item_data: 2162 tester = self._testers.get(item_data["label"]) 2163 if tester and not tester(): 2164 self.entryconfigure(i, state=tk.DISABLED) 2165 else: 2166 self.entryconfigure(i, state=tk.NORMAL) 2167 2168 def add(self, itemType, cnf={}, **kw): 2169 cnf = cnf or kw 2170 tester = cnf.get("tester") 2171 if "tester" in cnf: 2172 del cnf["tester"] 2173 2174 super().add(itemType, cnf) 2175 2176 itemdata = self.entryconfigure(self.index("end")) 2177 labeldata = itemdata.get("label") 2178 if labeldata: 2179 self._testers[labeldata] = tester 2180 2181 2182class TextMenu(MenuEx): 2183 def __init__(self, target): 2184 self.text = target 2185 MenuEx.__init__(self, target) 2186 self.add_basic_items() 2187 self.add_extra_items() 2188 2189 def add_basic_items(self): 2190 self.add_command(label=tr("Cut"), command=self.on_cut, tester=self.can_cut) 2191 self.add_command(label=tr("Copy"), command=self.on_copy, tester=self.can_copy) 2192 self.add_command(label=tr("Paste"), command=self.on_paste, tester=self.can_paste) 2193 2194 def add_extra_items(self): 2195 self.add_separator() 2196 self.add_command(label=tr("Select All"), command=self.on_select_all) 2197 2198 def on_cut(self): 2199 self.text.event_generate("<<Cut>>") 2200 2201 def on_copy(self): 2202 self.text.event_generate("<<Copy>>") 2203 2204 def on_paste(self): 2205 self.text.event_generate("<<Paste>>") 2206 2207 def on_select_all(self): 2208 self.text.event_generate("<<SelectAll>>") 2209 2210 def can_cut(self): 2211 return self.get_selected_text() and not self.selection_is_read_only() 2212 2213 def can_copy(self): 2214 return self.get_selected_text() 2215 2216 def can_paste(self): 2217 return not self.selection_is_read_only() 2218 2219 def get_selected_text(self): 2220 try: 2221 return self.text.get("sel.first", "sel.last") 2222 except TclError: 2223 return "" 2224 2225 def selection_is_read_only(self): 2226 if hasattr(self.text, "is_read_only"): 2227 return self.text.is_read_only() 2228 2229 return False 2230 2231 2232def create_url_label(master, url, text=None): 2233 import webbrowser 2234 2235 return create_action_label(master, text or url, lambda _: webbrowser.open(url)) 2236 2237 2238def create_action_label(master, text, click_handler, **kw): 2239 url_font = tkinter.font.nametofont("TkDefaultFont").copy() 2240 url_font.configure(underline=1) 2241 url_label = ttk.Label( 2242 master, text=text, style="Url.TLabel", cursor="hand2", font=url_font, **kw 2243 ) 2244 url_label.bind("<Button-1>", click_handler) 2245 return url_label 2246 2247 2248def get_size_option_name(window): 2249 return "layout." + type(window).__name__ + "_size" 2250 2251 2252def get_default_theme(): 2253 if running_on_windows(): 2254 return "Windows" 2255 elif running_on_rpi(): 2256 return "Raspberry Pi" 2257 else: 2258 return "Enhanced Clam" 2259 2260 2261def get_default_basic_theme(): 2262 if running_on_windows(): 2263 return "xpnative" 2264 else: 2265 return "clam" 2266 2267 2268EM_WIDTH = None 2269 2270 2271def ems_to_pixels(x): 2272 global EM_WIDTH 2273 if EM_WIDTH is None: 2274 EM_WIDTH = tkinter.font.nametofont("TkDefaultFont").measure("m") 2275 return int(EM_WIDTH * x) 2276 2277 2278_btn_padding = None 2279 2280 2281def set_text_if_different(widget, text) -> bool: 2282 if widget["text"] != text: 2283 widget["text"] = text 2284 return True 2285 else: 2286 return False 2287 2288 2289def tr_btn(s): 2290 """Translates button caption, adds padding to make sure text fits""" 2291 global _btn_padding 2292 if _btn_padding is None: 2293 _btn_padding = get_button_padding() 2294 2295 return _btn_padding + tr(s) + _btn_padding 2296 2297 2298def add_messagebox_parent_checker(): 2299 def wrap_with_parent_checker(original): 2300 def wrapper(*args, **options): 2301 _check_dialog_parent(options) 2302 return original(*args, **options) 2303 2304 return wrapper 2305 2306 from tkinter import messagebox 2307 2308 for name in [ 2309 "showinfo", 2310 "showwarning", 2311 "showerror", 2312 "askquestion", 2313 "askokcancel", 2314 "askyesno", 2315 "askyesnocancel", 2316 "askretrycancel", 2317 ]: 2318 fun = getattr(messagebox, name) 2319 setattr(messagebox, name, wrap_with_parent_checker(fun)) 2320 2321 2322if __name__ == "__main__": 2323 root = tk.Tk() 2324