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