| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105 |
- import uuid
- import weakref
- from contextlib import contextmanager
- import logging
- import math
- import os.path
- import pathlib
- import sys
- import tkinter as tk
- import tkinter.filedialog
- import tkinter.font
- import tkinter.messagebox
- from tkinter.simpledialog import SimpleDialog
- import numpy as np
- from PIL import Image, ImageTk
- import matplotlib as mpl
- from matplotlib import _api, backend_tools, cbook, _c_internal_utils
- from matplotlib.backend_bases import (
- _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2,
- TimerBase, ToolContainerBase, cursors, _Mode, MouseButton,
- CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent)
- from matplotlib._pylab_helpers import Gcf
- try:
- from . import _tkagg
- from ._tkagg import TK_PHOTO_COMPOSITE_OVERLAY, TK_PHOTO_COMPOSITE_SET
- except ImportError as e:
- # catch incompatibility of python-build-standalone with Tk
- cause1 = getattr(e, '__cause__', None)
- cause2 = getattr(cause1, '__cause__', None)
- if (isinstance(cause1, ImportError) and
- isinstance(cause2, AttributeError) and
- "'_tkinter' has no attribute '__file__'" in str(cause2)):
- is_uv_python = "/uv/python" in (os.path.realpath(sys.executable))
- if is_uv_python:
- raise ImportError(
- "Failed to import tkagg backend. You appear to be using an outdated "
- "version of uv's managed Python distribution which is not compatible "
- "with Tk. Please upgrade to the latest uv version, then update "
- "Python with: `uv python upgrade --reinstall`"
- ) from e
- else:
- raise ImportError(
- "Failed to import tkagg backend. This is likely caused by using a "
- "Python executable based on python-build-standalone, which is not "
- "compatible with Tk. Recent versions of python-build-standalone "
- "should be compatible with Tk. Please update your python version "
- "or select another backend."
- ) from e
- else:
- raise
- _log = logging.getLogger(__name__)
- cursord = {
- cursors.MOVE: "fleur",
- cursors.HAND: "hand2",
- cursors.POINTER: "arrow",
- cursors.SELECT_REGION: "crosshair",
- cursors.WAIT: "watch",
- cursors.RESIZE_HORIZONTAL: "sb_h_double_arrow",
- cursors.RESIZE_VERTICAL: "sb_v_double_arrow",
- }
- @contextmanager
- def _restore_foreground_window_at_end():
- foreground = _c_internal_utils.Win32_GetForegroundWindow()
- try:
- yield
- finally:
- if foreground and mpl.rcParams['tk.window_focus']:
- _c_internal_utils.Win32_SetForegroundWindow(foreground)
- _blit_args = {}
- # Initialize to a non-empty string that is not a Tcl command
- _blit_tcl_name = "mpl_blit_" + uuid.uuid4().hex
- def _blit(argsid):
- """
- Thin wrapper to blit called via tkapp.call.
- *argsid* is a unique string identifier to fetch the correct arguments from
- the ``_blit_args`` dict, since arguments cannot be passed directly.
- """
- photoimage, data, offsets, bbox, comp_rule = _blit_args.pop(argsid)
- if not photoimage.tk.call("info", "commands", photoimage):
- return
- _tkagg.blit(photoimage.tk.interpaddr(), str(photoimage), data, comp_rule, offsets,
- bbox)
- def blit(photoimage, aggimage, offsets, bbox=None):
- """
- Blit *aggimage* to *photoimage*.
- *offsets* is a tuple describing how to fill the ``offset`` field of the
- ``Tk_PhotoImageBlock`` struct: it should be (0, 1, 2, 3) for RGBA8888 data,
- (2, 1, 0, 3) for little-endian ARBG32 (i.e. GBRA8888) data and (1, 2, 3, 0)
- for big-endian ARGB32 (i.e. ARGB8888) data.
- If *bbox* is passed, it defines the region that gets blitted. That region
- will be composed with the previous data according to the alpha channel.
- Blitting will be clipped to pixels inside the canvas, including silently
- doing nothing if the *bbox* region is entirely outside the canvas.
- Tcl events must be dispatched to trigger a blit from a non-Tcl thread.
- """
- data = np.asarray(aggimage)
- height, width = data.shape[:2]
- if bbox is not None:
- (x1, y1), (x2, y2) = bbox.__array__()
- x1 = max(math.floor(x1), 0)
- x2 = min(math.ceil(x2), width)
- y1 = max(math.floor(y1), 0)
- y2 = min(math.ceil(y2), height)
- if (x1 > x2) or (y1 > y2):
- return
- bboxptr = (x1, x2, y1, y2)
- comp_rule = TK_PHOTO_COMPOSITE_OVERLAY
- else:
- bboxptr = (0, width, 0, height)
- comp_rule = TK_PHOTO_COMPOSITE_SET
- # NOTE: _tkagg.blit is thread unsafe and will crash the process if called
- # from a thread (GH#13293). Instead of blanking and blitting here,
- # use tkapp.call to post a cross-thread event if this function is called
- # from a non-Tcl thread.
- # tkapp.call coerces all arguments to strings, so to avoid string parsing
- # within _blit, pack up the arguments into a global data structure.
- args = photoimage, data, offsets, bboxptr, comp_rule
- # Need a unique key to avoid thread races.
- # Again, make the key a string to avoid string parsing in _blit.
- argsid = str(id(args))
- _blit_args[argsid] = args
- try:
- photoimage.tk.call(_blit_tcl_name, argsid)
- except tk.TclError as e:
- if "invalid command name" not in str(e):
- raise
- photoimage.tk.createcommand(_blit_tcl_name, _blit)
- photoimage.tk.call(_blit_tcl_name, argsid)
- class TimerTk(TimerBase):
- """Subclass of `backend_bases.TimerBase` using Tk timer events."""
- def __init__(self, parent, *args, **kwargs):
- self._timer = None
- super().__init__(*args, **kwargs)
- self.parent = parent
- def _timer_start(self):
- self._timer_stop()
- self._timer = self.parent.after(self._interval, self._on_timer)
- def _timer_stop(self):
- if self._timer is not None:
- self.parent.after_cancel(self._timer)
- self._timer = None
- def _on_timer(self):
- super()._on_timer()
- # Tk after() is only a single shot, so we need to add code here to
- # reset the timer if we're not operating in single shot mode. However,
- # if _timer is None, this means that _timer_stop has been called; so
- # don't recreate the timer in that case.
- if not self._single and self._timer:
- if self._interval > 0:
- self._timer = self.parent.after(self._interval, self._on_timer)
- else:
- # Edge case: Tcl after 0 *prepends* events to the queue
- # so a 0 interval does not allow any other events to run.
- # This incantation is cancellable and runs as fast as possible
- # while also allowing events and drawing every frame. GH#18236
- self._timer = self.parent.after_idle(
- lambda: self.parent.after(self._interval, self._on_timer)
- )
- else:
- self._timer = None
- class FigureCanvasTk(FigureCanvasBase):
- required_interactive_framework = "tk"
- manager_class = _api.classproperty(lambda cls: FigureManagerTk)
- def __init__(self, figure=None, master=None):
- super().__init__(figure)
- self._idle_draw_id = None
- self._event_loop_id = None
- w, h = self.get_width_height(physical=True)
- self._tkcanvas = tk.Canvas(
- master=master, background="white",
- width=w, height=h, borderwidth=0, highlightthickness=0)
- self._tkphoto = tk.PhotoImage(
- master=self._tkcanvas, width=w, height=h)
- self._tkcanvas_image_region = self._tkcanvas.create_image(
- w//2, h//2, image=self._tkphoto)
- self._tkcanvas.bind("<Configure>", self.resize)
- self._tkcanvas.bind("<Map>", self._update_device_pixel_ratio)
- self._tkcanvas.bind("<Key>", self.key_press)
- self._tkcanvas.bind("<Motion>", self.motion_notify_event)
- self._tkcanvas.bind("<Enter>", self.enter_notify_event)
- self._tkcanvas.bind("<Leave>", self.leave_notify_event)
- self._tkcanvas.bind("<KeyRelease>", self.key_release)
- for name in ["<Button-1>", "<Button-2>", "<Button-3>"]:
- self._tkcanvas.bind(name, self.button_press_event)
- for name in [
- "<Double-Button-1>", "<Double-Button-2>", "<Double-Button-3>"]:
- self._tkcanvas.bind(name, self.button_dblclick_event)
- for name in [
- "<ButtonRelease-1>", "<ButtonRelease-2>", "<ButtonRelease-3>"]:
- self._tkcanvas.bind(name, self.button_release_event)
- # Mouse wheel on Linux generates button 4/5 events
- for name in "<Button-4>", "<Button-5>":
- self._tkcanvas.bind(name, self.scroll_event)
- # Mouse wheel for windows goes to the window with the focus.
- # Since the canvas won't usually have the focus, bind the
- # event to the window containing the canvas instead.
- # See https://wiki.tcl-lang.org/3893 (mousewheel) for details
- root = self._tkcanvas.winfo_toplevel()
- # Prevent long-lived references via tkinter callback structure GH-24820
- weakself = weakref.ref(self)
- weakroot = weakref.ref(root)
- def scroll_event_windows(event):
- self = weakself()
- if self is None:
- root = weakroot()
- if root is not None:
- root.unbind("<MouseWheel>", scroll_event_windows_id)
- return
- return self.scroll_event_windows(event)
- scroll_event_windows_id = root.bind("<MouseWheel>", scroll_event_windows, "+")
- # Can't get destroy events by binding to _tkcanvas. Therefore, bind
- # to the window and filter.
- def filter_destroy(event):
- self = weakself()
- if self is None:
- root = weakroot()
- if root is not None:
- root.unbind("<Destroy>", filter_destroy_id)
- return
- if event.widget is self._tkcanvas:
- CloseEvent("close_event", self)._process()
- filter_destroy_id = root.bind("<Destroy>", filter_destroy, "+")
- self._tkcanvas.focus_set()
- self._rubberband_rect_black = None
- self._rubberband_rect_white = None
- def _update_device_pixel_ratio(self, event=None):
- ratio = None
- if sys.platform == 'win32':
- # Tk gives scaling with respect to 72 DPI, but Windows screens are
- # scaled vs 96 dpi, and pixel ratio settings are given in whole
- # percentages, so round to 2 digits.
- ratio = round(self._tkcanvas.tk.call('tk', 'scaling') / (96 / 72), 2)
- elif sys.platform == "linux":
- ratio = self._tkcanvas.winfo_fpixels('1i') / 96
- if ratio is not None and self._set_device_pixel_ratio(ratio):
- # The easiest way to resize the canvas is to resize the canvas
- # widget itself, since we implement all the logic for resizing the
- # canvas backing store on that event.
- w, h = self.get_width_height(physical=True)
- self._tkcanvas.configure(width=w, height=h)
- def resize(self, event):
- width, height = event.width, event.height
- # compute desired figure size in inches
- dpival = self.figure.dpi
- winch = width / dpival
- hinch = height / dpival
- self.figure.set_size_inches(winch, hinch, forward=False)
- self._tkcanvas.delete(self._tkcanvas_image_region)
- self._tkphoto.configure(width=int(width), height=int(height))
- self._tkcanvas_image_region = self._tkcanvas.create_image(
- int(width / 2), int(height / 2), image=self._tkphoto)
- ResizeEvent("resize_event", self)._process()
- self.draw_idle()
- def draw_idle(self):
- # docstring inherited
- if self._idle_draw_id:
- return
- def idle_draw(*args):
- try:
- self.draw()
- finally:
- self._idle_draw_id = None
- self._idle_draw_id = self._tkcanvas.after_idle(idle_draw)
- def get_tk_widget(self):
- """
- Return the Tk widget used to implement FigureCanvasTkAgg.
- Although the initial implementation uses a Tk canvas, this routine
- is intended to hide that fact.
- """
- return self._tkcanvas
- def _event_mpl_coords(self, event):
- # calling canvasx/canvasy allows taking scrollbars into account (i.e.
- # the top of the widget may have been scrolled out of view).
- return (self._tkcanvas.canvasx(event.x),
- # flipy so y=0 is bottom of canvas
- self.figure.bbox.height - self._tkcanvas.canvasy(event.y))
- def motion_notify_event(self, event):
- MouseEvent("motion_notify_event", self,
- *self._event_mpl_coords(event),
- buttons=self._mpl_buttons(event),
- modifiers=self._mpl_modifiers(event),
- guiEvent=event)._process()
- def enter_notify_event(self, event):
- LocationEvent("figure_enter_event", self,
- *self._event_mpl_coords(event),
- modifiers=self._mpl_modifiers(event),
- guiEvent=event)._process()
- def leave_notify_event(self, event):
- LocationEvent("figure_leave_event", self,
- *self._event_mpl_coords(event),
- modifiers=self._mpl_modifiers(event),
- guiEvent=event)._process()
- def button_press_event(self, event, dblclick=False):
- # set focus to the canvas so that it can receive keyboard events
- self._tkcanvas.focus_set()
- num = getattr(event, 'num', None)
- if sys.platform == 'darwin': # 2 and 3 are reversed.
- num = {2: 3, 3: 2}.get(num, num)
- MouseEvent("button_press_event", self,
- *self._event_mpl_coords(event), num, dblclick=dblclick,
- modifiers=self._mpl_modifiers(event),
- guiEvent=event)._process()
- def button_dblclick_event(self, event):
- self.button_press_event(event, dblclick=True)
- def button_release_event(self, event):
- num = getattr(event, 'num', None)
- if sys.platform == 'darwin': # 2 and 3 are reversed.
- num = {2: 3, 3: 2}.get(num, num)
- MouseEvent("button_release_event", self,
- *self._event_mpl_coords(event), num,
- modifiers=self._mpl_modifiers(event),
- guiEvent=event)._process()
- def scroll_event(self, event):
- num = getattr(event, 'num', None)
- step = 1 if num == 4 else -1 if num == 5 else 0
- MouseEvent("scroll_event", self,
- *self._event_mpl_coords(event), step=step,
- modifiers=self._mpl_modifiers(event),
- guiEvent=event)._process()
- def scroll_event_windows(self, event):
- """MouseWheel event processor"""
- # need to find the window that contains the mouse
- w = event.widget.winfo_containing(event.x_root, event.y_root)
- if w != self._tkcanvas:
- return
- x = self._tkcanvas.canvasx(event.x_root - w.winfo_rootx())
- y = (self.figure.bbox.height
- - self._tkcanvas.canvasy(event.y_root - w.winfo_rooty()))
- step = event.delta / 120
- MouseEvent("scroll_event", self,
- x, y, step=step, modifiers=self._mpl_modifiers(event),
- guiEvent=event)._process()
- @staticmethod
- def _mpl_buttons(event): # See _mpl_modifiers.
- # NOTE: This fails to report multiclicks on macOS; only one button is
- # reported (multiclicks work correctly on Linux & Windows).
- modifiers = [
- # macOS appears to swap right and middle (look for "Swap buttons
- # 2/3" in tk/macosx/tkMacOSXMouseEvent.c).
- (MouseButton.LEFT, 1 << 8),
- (MouseButton.RIGHT, 1 << 9),
- (MouseButton.MIDDLE, 1 << 10),
- (MouseButton.BACK, 1 << 11),
- (MouseButton.FORWARD, 1 << 12),
- ] if sys.platform == "darwin" else [
- (MouseButton.LEFT, 1 << 8),
- (MouseButton.MIDDLE, 1 << 9),
- (MouseButton.RIGHT, 1 << 10),
- (MouseButton.BACK, 1 << 11),
- (MouseButton.FORWARD, 1 << 12),
- ]
- # State *before* press/release.
- return [name for name, mask in modifiers if event.state & mask]
- @staticmethod
- def _mpl_modifiers(event, *, exclude=None):
- # Add modifier keys to the key string. Bit values are inferred from
- # the implementation of tkinter.Event.__repr__ (1, 2, 4, 8, ... =
- # Shift, Lock, Control, Mod1, ..., Mod5, Button1, ..., Button5)
- # In general, the modifier key is excluded from the modifier flag,
- # however this is not the case on "darwin", so double check that
- # we aren't adding repeat modifier flags to a modifier key.
- modifiers = [
- ("ctrl", 1 << 2, "control"),
- ("alt", 1 << 17, "alt"),
- ("shift", 1 << 0, "shift"),
- ] if sys.platform == "win32" else [
- ("ctrl", 1 << 2, "control"),
- ("alt", 1 << 4, "alt"),
- ("shift", 1 << 0, "shift"),
- ("cmd", 1 << 3, "cmd"),
- ] if sys.platform == "darwin" else [
- ("ctrl", 1 << 2, "control"),
- ("alt", 1 << 3, "alt"),
- ("shift", 1 << 0, "shift"),
- ("super", 1 << 6, "super"),
- ]
- return [name for name, mask, key in modifiers
- if event.state & mask and exclude != key]
- def _get_key(self, event):
- unikey = event.char
- key = cbook._unikey_or_keysym_to_mplkey(unikey, event.keysym)
- if key is not None:
- mods = self._mpl_modifiers(event, exclude=key)
- # shift is not added to the keys as this is already accounted for.
- if "shift" in mods and unikey:
- mods.remove("shift")
- return "+".join([*mods, key])
- def key_press(self, event):
- KeyEvent("key_press_event", self,
- self._get_key(event), *self._event_mpl_coords(event),
- guiEvent=event)._process()
- def key_release(self, event):
- KeyEvent("key_release_event", self,
- self._get_key(event), *self._event_mpl_coords(event),
- guiEvent=event)._process()
- def new_timer(self, *args, **kwargs):
- # docstring inherited
- return TimerTk(self._tkcanvas, *args, **kwargs)
- def flush_events(self):
- # docstring inherited
- self._tkcanvas.update()
- def start_event_loop(self, timeout=0):
- # docstring inherited
- if timeout > 0:
- milliseconds = int(1000 * timeout)
- if milliseconds > 0:
- self._event_loop_id = self._tkcanvas.after(
- milliseconds, self.stop_event_loop)
- else:
- self._event_loop_id = self._tkcanvas.after_idle(
- self.stop_event_loop)
- self._tkcanvas.mainloop()
- def stop_event_loop(self):
- # docstring inherited
- if self._event_loop_id:
- self._tkcanvas.after_cancel(self._event_loop_id)
- self._event_loop_id = None
- self._tkcanvas.quit()
- def set_cursor(self, cursor):
- try:
- self._tkcanvas.configure(cursor=cursord[cursor])
- except tkinter.TclError:
- pass
- class FigureManagerTk(FigureManagerBase):
- """
- Attributes
- ----------
- canvas : `FigureCanvas`
- The FigureCanvas instance
- num : int or str
- The Figure number
- toolbar : tk.Toolbar
- The tk.Toolbar
- window : tk.Window
- The tk.Window
- """
- _owns_mainloop = False
- def __init__(self, canvas, num, window):
- self.window = window
- super().__init__(canvas, num)
- self.window.withdraw()
- # packing toolbar first, because if space is getting low, last packed
- # widget is getting shrunk first (-> the canvas)
- self.canvas._tkcanvas.pack(side=tk.TOP, fill=tk.BOTH, expand=1)
- # If the window has per-monitor DPI awareness, then setup a Tk variable
- # to store the DPI, which will be updated by the C code, and the trace
- # will handle it on the Python side.
- window_frame = int(window.wm_frame(), 16)
- self._window_dpi = tk.IntVar(master=window, value=96,
- name=f'window_dpi{window_frame}')
- self._window_dpi_cbname = ''
- if _tkagg.enable_dpi_awareness(window_frame, window.tk.interpaddr()):
- self._window_dpi_cbname = self._window_dpi.trace_add(
- 'write', self._update_window_dpi)
- self._shown = False
- @classmethod
- def create_with_canvas(cls, canvas_class, figure, num):
- # docstring inherited
- with _restore_foreground_window_at_end():
- if cbook._get_running_interactive_framework() is None:
- cbook._setup_new_guiapp()
- _c_internal_utils.Win32_SetProcessDpiAwareness_max()
- window = tk.Tk(className="matplotlib")
- window.withdraw()
- # Put a Matplotlib icon on the window rather than the default tk
- # icon. See https://www.tcl.tk/man/tcl/TkCmd/wm.html#M50
- #
- # `ImageTk` can be replaced with `tk` whenever the minimum
- # supported Tk version is increased to 8.6, as Tk 8.6+ natively
- # supports PNG images.
- icon_fname = str(cbook._get_data_path(
- 'images/matplotlib.png'))
- icon_img = ImageTk.PhotoImage(file=icon_fname, master=window)
- icon_fname_large = str(cbook._get_data_path(
- 'images/matplotlib_large.png'))
- icon_img_large = ImageTk.PhotoImage(
- file=icon_fname_large, master=window)
- window.iconphoto(False, icon_img_large, icon_img)
- canvas = canvas_class(figure, master=window)
- manager = cls(canvas, num, window)
- if mpl.is_interactive():
- manager.show()
- canvas.draw_idle()
- return manager
- @classmethod
- def start_main_loop(cls):
- managers = Gcf.get_all_fig_managers()
- if managers:
- first_manager = managers[0]
- manager_class = type(first_manager)
- if manager_class._owns_mainloop:
- return
- manager_class._owns_mainloop = True
- try:
- first_manager.window.mainloop()
- finally:
- manager_class._owns_mainloop = False
- def _update_window_dpi(self, *args):
- newdpi = self._window_dpi.get()
- self.window.call('tk', 'scaling', newdpi / 72)
- if self.toolbar and hasattr(self.toolbar, '_rescale'):
- self.toolbar._rescale()
- self.canvas._update_device_pixel_ratio()
- def resize(self, width, height):
- max_size = 1_400_000 # the measured max on xorg 1.20.8 was 1_409_023
- if (width > max_size or height > max_size) and sys.platform == 'linux':
- raise ValueError(
- 'You have requested to resize the '
- f'Tk window to ({width}, {height}), one of which '
- f'is bigger than {max_size}. At larger sizes xorg will '
- 'either exit with an error on newer versions (~1.20) or '
- 'cause corruption on older version (~1.19). We '
- 'do not expect a window over a million pixel wide or tall '
- 'to be intended behavior.')
- self.canvas._tkcanvas.configure(width=width, height=height)
- def show(self):
- with _restore_foreground_window_at_end():
- if not self._shown:
- def destroy(*args):
- Gcf.destroy(self)
- self.window.protocol("WM_DELETE_WINDOW", destroy)
- self.window.deiconify()
- self.canvas._tkcanvas.focus_set()
- else:
- self.canvas.draw_idle()
- if mpl.rcParams['figure.raise_window']:
- self.canvas.manager.window.attributes('-topmost', 1)
- self.canvas.manager.window.attributes('-topmost', 0)
- self._shown = True
- def destroy(self, *args):
- if self.canvas._idle_draw_id:
- self.canvas._tkcanvas.after_cancel(self.canvas._idle_draw_id)
- if self.canvas._event_loop_id:
- self.canvas._tkcanvas.after_cancel(self.canvas._event_loop_id)
- if self._window_dpi_cbname:
- self._window_dpi.trace_remove('write', self._window_dpi_cbname)
- # NOTE: events need to be flushed before issuing destroy (GH #9956),
- # however, self.window.update() can break user code. An async callback
- # is the safest way to achieve a complete draining of the event queue,
- # but it leaks if no tk event loop is running. Therefore we explicitly
- # check for an event loop and choose our best guess.
- def delayed_destroy():
- self.window.destroy()
- if self._owns_mainloop and not Gcf.get_num_fig_managers():
- self.window.quit()
- if cbook._get_running_interactive_framework() == "tk":
- # "after idle after 0" avoids Tcl error/race (GH #19940)
- self.window.after_idle(self.window.after, 0, delayed_destroy)
- else:
- self.window.update()
- delayed_destroy()
- def get_window_title(self):
- return self.window.wm_title()
- def set_window_title(self, title):
- self.window.wm_title(title)
- def full_screen_toggle(self):
- is_fullscreen = bool(self.window.attributes('-fullscreen'))
- self.window.attributes('-fullscreen', not is_fullscreen)
- class NavigationToolbar2Tk(NavigationToolbar2, tk.Frame):
- def __init__(self, canvas, window=None, *, pack_toolbar=True):
- """
- Parameters
- ----------
- canvas : `FigureCanvas`
- The figure canvas on which to operate.
- window : tk.Window
- The tk.Window which owns this toolbar.
- pack_toolbar : bool, default: True
- If True, add the toolbar to the parent's pack manager's packing
- list during initialization with ``side="bottom"`` and ``fill="x"``.
- If you want to use the toolbar with a different layout manager, use
- ``pack_toolbar=False``.
- """
- if window is None:
- window = canvas.get_tk_widget().master
- tk.Frame.__init__(self, master=window, borderwidth=2,
- width=int(canvas.figure.bbox.width), height=50)
- self._buttons = {}
- for text, tooltip_text, image_file, callback in self.toolitems:
- if text is None:
- # Add a spacer; return value is unused.
- self._Spacer()
- else:
- self._buttons[text] = button = self._Button(
- text,
- str(cbook._get_data_path(f"images/{image_file}.png")),
- toggle=callback in ["zoom", "pan"],
- command=getattr(self, callback),
- )
- if tooltip_text is not None:
- add_tooltip(button, tooltip_text)
- self._label_font = tkinter.font.Font(root=window, size=10)
- # This filler item ensures the toolbar is always at least two text
- # lines high. Otherwise the canvas gets redrawn as the mouse hovers
- # over images because those use two-line messages which resize the
- # toolbar.
- label = tk.Label(master=self, font=self._label_font,
- text='\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}')
- label.pack(side=tk.RIGHT)
- self.message = tk.StringVar(master=self)
- self._message_label = tk.Label(master=self, font=self._label_font,
- textvariable=self.message,
- justify=tk.RIGHT)
- self._message_label.pack(side=tk.RIGHT)
- NavigationToolbar2.__init__(self, canvas)
- if pack_toolbar:
- self.pack(side=tk.BOTTOM, fill=tk.X)
- def _rescale(self):
- """
- Scale all children of the toolbar to current DPI setting.
- Before this is called, the Tk scaling setting will have been updated to
- match the new DPI. Tk widgets do not update for changes to scaling, but
- all measurements made after the change will match the new scaling. Thus
- this function re-applies all the same sizes in points, which Tk will
- scale correctly to pixels.
- """
- for widget in self.winfo_children():
- if isinstance(widget, (tk.Button, tk.Checkbutton)):
- if hasattr(widget, '_image_file'):
- # Explicit class because ToolbarTk calls _rescale.
- NavigationToolbar2Tk._set_image_for_button(self, widget)
- else:
- # Text-only button is handled by the font setting instead.
- pass
- elif isinstance(widget, tk.Frame):
- widget.configure(height='18p')
- widget.pack_configure(padx='3p')
- elif isinstance(widget, tk.Label):
- pass # Text is handled by the font setting instead.
- else:
- _log.warning('Unknown child class %s', widget.winfo_class)
- self._label_font.configure(size=10)
- def _update_buttons_checked(self):
- # sync button checkstates to match active mode
- for text, mode in [('Zoom', _Mode.ZOOM), ('Pan', _Mode.PAN)]:
- if text in self._buttons:
- if self.mode == mode:
- self._buttons[text].select() # NOT .invoke()
- else:
- self._buttons[text].deselect()
- def pan(self, *args):
- super().pan(*args)
- self._update_buttons_checked()
- def zoom(self, *args):
- super().zoom(*args)
- self._update_buttons_checked()
- def set_message(self, s):
- self.message.set(s)
- def draw_rubberband(self, event, x0, y0, x1, y1):
- # Block copied from remove_rubberband for backend_tools convenience.
- if self.canvas._rubberband_rect_white:
- self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_white)
- if self.canvas._rubberband_rect_black:
- self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_black)
- height = self.canvas.figure.bbox.height
- y0 = height - y0
- y1 = height - y1
- self.canvas._rubberband_rect_black = (
- self.canvas._tkcanvas.create_rectangle(
- x0, y0, x1, y1))
- self.canvas._rubberband_rect_white = (
- self.canvas._tkcanvas.create_rectangle(
- x0, y0, x1, y1, outline='white', dash=(3, 3)))
- def remove_rubberband(self):
- if self.canvas._rubberband_rect_white:
- self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_white)
- self.canvas._rubberband_rect_white = None
- if self.canvas._rubberband_rect_black:
- self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_black)
- self.canvas._rubberband_rect_black = None
- def _set_image_for_button(self, button):
- """
- Set the image for a button based on its pixel size.
- The pixel size is determined by the DPI scaling of the window.
- """
- if button._image_file is None:
- return
- # Allow _image_file to be relative to Matplotlib's "images" data
- # directory.
- path_regular = cbook._get_data_path('images', button._image_file)
- path_large = path_regular.with_name(
- path_regular.name.replace('.png', '_large.png'))
- size = button.winfo_pixels('18p')
- # Nested functions because ToolbarTk calls _Button.
- def _get_color(color_name):
- # `winfo_rgb` returns an (r, g, b) tuple in the range 0-65535
- return button.winfo_rgb(button.cget(color_name))
- def _is_dark(color):
- if isinstance(color, str):
- color = _get_color(color)
- return max(color) < 65535 / 2
- def _recolor_icon(image, color):
- image_data = np.asarray(image).copy()
- black_mask = (image_data[..., :3] == 0).all(axis=-1)
- image_data[black_mask, :3] = color
- return Image.fromarray(image_data)
- # Use the high-resolution (48x48 px) icon if it exists and is needed
- with Image.open(path_large if (size > 24 and path_large.exists())
- else path_regular) as im:
- # assure a RGBA image as foreground color is RGB
- im = im.convert("RGBA")
- image = ImageTk.PhotoImage(im.resize((size, size)), master=self)
- button._ntimage = image
- # create a version of the icon with the button's text color
- foreground = (255 / 65535) * np.array(
- button.winfo_rgb(button.cget("foreground")))
- im_alt = _recolor_icon(im, foreground)
- image_alt = ImageTk.PhotoImage(
- im_alt.resize((size, size)), master=self)
- button._ntimage_alt = image_alt
- if _is_dark("background"):
- # For Checkbuttons, we need to set `image` and `selectimage` at
- # the same time. Otherwise, when updating the `image` option
- # (such as when changing DPI), if the old `selectimage` has
- # just been overwritten, Tk will throw an error.
- image_kwargs = {"image": image_alt}
- else:
- image_kwargs = {"image": image}
- # Checkbuttons may switch the background to `selectcolor` in the
- # checked state, so check separately which image it needs to use in
- # that state to still ensure enough contrast with the background.
- if (
- isinstance(button, tk.Checkbutton)
- and button.cget("selectcolor") != ""
- ):
- if self._windowingsystem != "x11":
- selectcolor = "selectcolor"
- else:
- # On X11, selectcolor isn't used directly for indicator-less
- # buttons. See `::tk::CheckEnter` in the Tk button.tcl source
- # code for details.
- r1, g1, b1 = _get_color("selectcolor")
- r2, g2, b2 = _get_color("activebackground")
- selectcolor = ((r1+r2)/2, (g1+g2)/2, (b1+b2)/2)
- if _is_dark(selectcolor):
- image_kwargs["selectimage"] = image_alt
- else:
- image_kwargs["selectimage"] = image
- button.configure(**image_kwargs, height='18p', width='18p')
- def _Button(self, text, image_file, toggle, command):
- if not toggle:
- b = tk.Button(
- master=self, text=text, command=command,
- relief="flat", overrelief="groove", borderwidth=1,
- )
- else:
- # There is a bug in tkinter included in some python 3.6 versions
- # that without this variable, produces a "visual" toggling of
- # other near checkbuttons
- # https://bugs.python.org/issue29402
- # https://bugs.python.org/issue25684
- var = tk.IntVar(master=self)
- b = tk.Checkbutton(
- master=self, text=text, command=command, indicatoron=False,
- variable=var, offrelief="flat", overrelief="groove",
- borderwidth=1
- )
- b.var = var
- b._image_file = image_file
- if image_file is not None:
- # Explicit class because ToolbarTk calls _Button.
- NavigationToolbar2Tk._set_image_for_button(self, b)
- else:
- b.configure(font=self._label_font)
- b.pack(side=tk.LEFT)
- return b
- def _Spacer(self):
- # Buttons are also 18pt high.
- s = tk.Frame(master=self, height='18p', relief=tk.RIDGE, bg='DarkGray')
- s.pack(side=tk.LEFT, padx='3p')
- return s
- def save_figure(self, *args):
- filetypes = self.canvas.get_supported_filetypes_grouped()
- tk_filetypes = [
- (name, " ".join(f"*.{ext}" for ext in exts))
- for name, exts in sorted(filetypes.items())
- ]
- default_extension = self.canvas.get_default_filetype()
- default_filetype = self.canvas.get_supported_filetypes()[default_extension]
- filetype_variable = tk.StringVar(self.canvas.get_tk_widget(), default_filetype)
- # adding a default extension seems to break the
- # asksaveasfilename dialog when you choose various save types
- # from the dropdown. Passing in the empty string seems to
- # work - JDH!
- # defaultextension = self.canvas.get_default_filetype()
- defaultextension = ''
- initialdir = os.path.expanduser(mpl.rcParams['savefig.directory'])
- # get_default_filename() contains the default extension. On some platforms,
- # choosing a different extension from the dropdown does not overwrite it,
- # so we need to remove it to make the dropdown functional.
- initialfile = pathlib.Path(self.canvas.get_default_filename()).stem
- fname = tkinter.filedialog.asksaveasfilename(
- master=self.canvas.get_tk_widget().master,
- title='Save the figure',
- filetypes=tk_filetypes,
- defaultextension=defaultextension,
- initialdir=initialdir,
- initialfile=initialfile,
- typevariable=filetype_variable
- )
- if fname in ["", ()]:
- return None
- # Save dir for next time, unless empty str (i.e., use cwd).
- if initialdir != "":
- mpl.rcParams['savefig.directory'] = (
- os.path.dirname(str(fname)))
- # If the filename contains an extension, let savefig() infer the file
- # format from that. If it does not, use the selected dropdown option.
- if pathlib.Path(fname).suffix[1:] != "":
- extension = None
- else:
- extension = filetypes[filetype_variable.get()][0]
- try:
- self.canvas.figure.savefig(fname, format=extension)
- return fname
- except Exception as e:
- tkinter.messagebox.showerror("Error saving file", str(e))
- def set_history_buttons(self):
- state_map = {True: tk.NORMAL, False: tk.DISABLED}
- can_back = self._nav_stack._pos > 0
- can_forward = self._nav_stack._pos < len(self._nav_stack) - 1
- if "Back" in self._buttons:
- self._buttons['Back']['state'] = state_map[can_back]
- if "Forward" in self._buttons:
- self._buttons['Forward']['state'] = state_map[can_forward]
- def add_tooltip(widget, text):
- tipwindow = None
- def showtip(event):
- """Display text in tooltip window."""
- nonlocal tipwindow
- if tipwindow or not text:
- return
- x, y, _, _ = widget.bbox("insert")
- x = x + widget.winfo_rootx() + widget.winfo_width()
- y = y + widget.winfo_rooty()
- tipwindow = tk.Toplevel(widget)
- tipwindow.overrideredirect(1)
- tipwindow.geometry(f"+{x}+{y}")
- try: # For Mac OS
- tipwindow.tk.call("::tk::unsupported::MacWindowStyle",
- "style", tipwindow._w,
- "help", "noActivates")
- except tk.TclError:
- pass
- label = tk.Label(tipwindow, text=text, justify=tk.LEFT,
- relief=tk.SOLID, borderwidth=1)
- label.pack(ipadx=1)
- def hidetip(event):
- nonlocal tipwindow
- if tipwindow:
- tipwindow.destroy()
- tipwindow = None
- widget.bind("<Enter>", showtip)
- widget.bind("<Leave>", hidetip)
- @backend_tools._register_tool_class(FigureCanvasTk)
- class RubberbandTk(backend_tools.RubberbandBase):
- def draw_rubberband(self, x0, y0, x1, y1):
- NavigationToolbar2Tk.draw_rubberband(
- self._make_classic_style_pseudo_toolbar(), None, x0, y0, x1, y1)
- def remove_rubberband(self):
- NavigationToolbar2Tk.remove_rubberband(
- self._make_classic_style_pseudo_toolbar())
- class ToolbarTk(ToolContainerBase, tk.Frame):
- def __init__(self, toolmanager, window=None):
- ToolContainerBase.__init__(self, toolmanager)
- if window is None:
- window = self.toolmanager.canvas.get_tk_widget().master
- xmin, xmax = self.toolmanager.canvas.figure.bbox.intervalx
- height, width = 50, xmax - xmin
- tk.Frame.__init__(self, master=window,
- width=int(width), height=int(height),
- borderwidth=2)
- self._label_font = tkinter.font.Font(size=10)
- # This filler item ensures the toolbar is always at least two text
- # lines high. Otherwise the canvas gets redrawn as the mouse hovers
- # over images because those use two-line messages which resize the
- # toolbar.
- label = tk.Label(master=self, font=self._label_font,
- text='\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}')
- label.pack(side=tk.RIGHT)
- self._message = tk.StringVar(master=self)
- self._message_label = tk.Label(master=self, font=self._label_font,
- textvariable=self._message)
- self._message_label.pack(side=tk.RIGHT)
- self._toolitems = {}
- self.pack(side=tk.TOP, fill=tk.X)
- self._groups = {}
- def _rescale(self):
- return NavigationToolbar2Tk._rescale(self)
- def add_toolitem(
- self, name, group, position, image_file, description, toggle):
- frame = self._get_groupframe(group)
- buttons = frame.pack_slaves()
- if position >= len(buttons) or position < 0:
- before = None
- else:
- before = buttons[position]
- button = NavigationToolbar2Tk._Button(frame, name, image_file, toggle,
- lambda: self._button_click(name))
- button.pack_configure(before=before)
- if description is not None:
- add_tooltip(button, description)
- self._toolitems.setdefault(name, [])
- self._toolitems[name].append(button)
- def _get_groupframe(self, group):
- if group not in self._groups:
- if self._groups:
- self._add_separator()
- frame = tk.Frame(master=self, borderwidth=0)
- frame.pack(side=tk.LEFT, fill=tk.Y)
- frame._label_font = self._label_font
- self._groups[group] = frame
- return self._groups[group]
- def _add_separator(self):
- return NavigationToolbar2Tk._Spacer(self)
- def _button_click(self, name):
- self.trigger_tool(name)
- def toggle_toolitem(self, name, toggled):
- if name not in self._toolitems:
- return
- for toolitem in self._toolitems[name]:
- if toggled:
- toolitem.select()
- else:
- toolitem.deselect()
- def remove_toolitem(self, name):
- for toolitem in self._toolitems.pop(name, []):
- toolitem.pack_forget()
- def set_message(self, s):
- self._message.set(s)
- @backend_tools._register_tool_class(FigureCanvasTk)
- class SaveFigureTk(backend_tools.SaveFigureBase):
- def trigger(self, *args):
- NavigationToolbar2Tk.save_figure(
- self._make_classic_style_pseudo_toolbar())
- @backend_tools._register_tool_class(FigureCanvasTk)
- class ConfigureSubplotsTk(backend_tools.ConfigureSubplotsBase):
- def trigger(self, *args):
- NavigationToolbar2Tk.configure_subplots(self)
- @backend_tools._register_tool_class(FigureCanvasTk)
- class HelpTk(backend_tools.ToolHelpBase):
- def trigger(self, *args):
- dialog = SimpleDialog(
- self.figure.canvas._tkcanvas, self._get_help_text(), ["OK"])
- dialog.done = lambda num: dialog.frame.master.withdraw()
- Toolbar = ToolbarTk
- FigureManagerTk._toolbar2_class = NavigationToolbar2Tk
- FigureManagerTk._toolmanager_toolbar_class = ToolbarTk
- @_Backend.export
- class _BackendTk(_Backend):
- backend_version = tk.TkVersion
- FigureCanvas = FigureCanvasTk
- FigureManager = FigureManagerTk
- mainloop = FigureManagerTk.start_main_loop
|