From 9647197f25f72abcbda6c8f4cdc8ca6d481837d2 Mon Sep 17 00:00:00 2001 From: OceanWolf Date: Sat, 10 Aug 2024 02:54:04 +0200 Subject: [PATCH] Refactoring the WindowGTK classes as part 1 of the larger MEP27 refactor, splitting up previous PRs into smaller easier to review chunks. --- lib/matplotlib/backend_bases.py | 178 +++++++++++++++++++++++- lib/matplotlib/backends/_backend_gtk.py | 165 +++++++++++++++------- lib/matplotlib/backends/backend_gtk3.py | 34 ++++- lib/matplotlib/backends/backend_gtk4.py | 21 ++- lib/matplotlib/cbook.py | 12 ++ 5 files changed, 354 insertions(+), 56 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index f4273bc03919..68992fbb29d6 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -17,6 +17,9 @@ such as `KeyEvent` and `MouseEvent` store the meta data like keys and buttons pressed, x and y locations in pixel and `~.axes.Axes` coordinates. +`WindowBase` + The base class to display a window. + `ShowBase` The base class for the ``Show`` class of each interactive backend; the 'show' callable is then set to ``Show.__call__``. @@ -1686,7 +1689,60 @@ def save_args_and_handle_sigint(*args): old_sigint_handler(*handler_args) -class FigureCanvasBase: +class ExpandableBase(object): + """ + Base class for GUI elements that can expand to fill the area given to them + by the encapsulating container (e.g. the main window). + At the moment this class does not do anything apart from mark such classes, + but this may well change at a later date, PRs welcome. + """ + pass + + +class FlowBase(object): + """ + Base mixin class for all GUI elements that can flow, aka laid out in + different directions. + The MPL window class deals with the manipulation of this mixin, so users + don't actually need to interact with this class. + Classes the implement this class must override the _update_flow method. + """ + flow_types = ['horizontal', 'vertical'] + + def __init__(self, flow='horizontal', flow_locked=False, **kwargs): + super(FlowBase, self).__init__(**kwargs) + self.flow_locked = flow_locked + self.flow = flow + + @property + def flow(self): + """ + The direction of flow, one of the strings in `flow_type`. + """ + return FlowBase.flow_types[self._flow] + + @flow.setter + def flow(self, flow): + if self.flow_locked: + return + + try: + self._flow = FlowBase.flow_types.index(flow) + except ValueError: + raise ValueError('Flow (%s), not in list %s' % (flow, FlowBase.flow_types)) + + self._update_flow() + + def _update_flow(self): + """ + Classes that extend FlowBase must override this method. + You can use the internal property self._flow whereby + flow_types[self._flow] gives the current flow. + """ + raise NotImplementedError + + +class FigureCanvasBase(ExpandableBase): """ The canvas the figure renders into. @@ -2581,6 +2637,126 @@ class NonGuiException(Exception): pass +class WindowEvent(object): + def __init__(self, name, window): + self.name = name + self.window = window + + +class WindowBase(cbook.EventEmitter): + """ + The base class to show a window on screen. + + Parameters + ---------- + title : str + The title of the window. + """ + + def __init__(self, title, **kwargs): + super(WindowBase, self).__init__(**kwargs) + + def show(self): + """ + For GUI backends, show the figure window and redraw. + For non-GUI backends, raise an exception to be caught + by :meth:`~matplotlib.figure.Figure.show`, for an + optional warning. + """ + raise NonGuiException() + + def destroy(self): + """Destroys the window""" + pass + + def set_fullscreen(self, fullscreen): + """ + Whether to show the window fullscreen or not, GUI only. + + Parameters + ---------- + fullscreen : bool + True for yes, False for no. + """ + pass + + def set_default_size(self, width, height): + """ + Sets the default size of the window, defaults to a simple resize. + + Parameters + ---------- + width : int + The default width (in pixels) of the window. + height : int + The default height (in pixels) of the window. + """ + self.resize(width, height) + + def resize(self, width, height): + """ + For gui backends, resizes the window. + + Parameters + ---------- + width : int + The new width (in pixels) for the window. + height : int + The new height (in pixels) for the window. + """ + pass + + def get_window_title(self): + """ + Get the title text of the window containing the figure. + Return None for non-GUI backends (e.g., a PS backend). + + Returns + ------- + str : The window's title. + """ + return 'image' + + def set_window_title(self, title): + """ + Set the title text of the window containing the figure. Note that + this has no effect for non-GUI backends (e.g., a PS backend). + + Parameters + ---------- + title : str + The title of the window. + """ + pass + + def add_element(self, element, place): + """ + Adds a gui widget to the window. + This has no effect for non-GUI backends and properties only apply + to those backends that support them, or have a suitable workaround. + + Parameters + ---------- + element : A gui element. + The element to add to the window + place : string + The location to place the element, either compass points north, + east, south, west, or center. + """ + pass + + def destroy_event(self, *args): + """ + Fires this event when the window wants to destroy itself. + + Note this method should hook up to the backend's internal window's + close event. + """ + s = 'window_destroy_event' + event = WindowEvent(s, self) + self._callbacks.process(s, event) + + class FigureManagerBase: """ A backend-independent abstraction of a figure container and controller. diff --git a/lib/matplotlib/backends/_backend_gtk.py b/lib/matplotlib/backends/_backend_gtk.py index 565d92932023..c6e42a196cd8 100644 --- a/lib/matplotlib/backends/_backend_gtk.py +++ b/lib/matplotlib/backends/_backend_gtk.py @@ -10,7 +10,7 @@ from matplotlib._pylab_helpers import Gcf from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, - TimerBase) + TimerBase, WindowBase, ExpandableBase) from matplotlib.backend_tools import Cursors import gi @@ -118,6 +118,109 @@ class _FigureCanvasGTK(FigureCanvasBase): _timer_cls = TimerGTK +_flow = [Gtk.Orientation.HORIZONTAL, Gtk.Orientation.VERTICAL] + + +class _WindowGTK(WindowBase, Gtk.Window): + # Must be implemented in GTK3/GTK4 backends: + # * _add_element - to add an widget to a container + # * _setup_signals + # * _get_self - a method to ensure that we have been fully initialised + + def __init__(self, title, **kwargs): + super().__init__(title=title, **kwargs) + + self.set_window_title(title) + + self._layout = {} + self._setup_box('_outer', Gtk.Orientation.VERTICAL, False, None) + self._setup_box('north', Gtk.Orientation.VERTICAL, False, '_outer') + self._setup_box('_middle', Gtk.Orientation.HORIZONTAL, True, '_outer') + self._setup_box('south', Gtk.Orientation.VERTICAL, False, '_outer') + + self._setup_box('west', Gtk.Orientation.HORIZONTAL, False, '_middle') + self._setup_box('center', Gtk.Orientation.VERTICAL, True, '_middle') + self._setup_box('east', Gtk.Orientation.HORIZONTAL, False, '_middle') + + self.set_child(self._layout['_outer']) + + self._setup_signals() + + def _setup_box(self, name, orientation, grow, parent): + self._layout[name] = Gtk.Box(orientation=orientation) + if parent: + self._add_element(self._layout[parent], self._layout[name], True, grow) + self._layout[name].show() + + def add_element(self, element, place): + element.show() + + # Get the flow of the element (the opposite of the container) + flow_index = not _flow.index(self._layout[place].get_orientation()) + flow = _flow[flow_index] + separator = Gtk.Separator(orientation=flow) + separator.show() + + try: + element.flow = element.flow_types[flow_index] + except AttributeError: + pass + + # Determine if this element should fill all the space given to it + expand = isinstance(element, ExpandableBase) + + if place in ['north', 'west', 'center']: + to_start = True + elif place in ['south', 'east']: + to_start = False + else: + raise KeyError('Unknown value for place, %s' % place) + + self._add_element(self._layout[place], element, to_start, expand) + self._add_element(self._layout[place], separator, to_start, False) + + h = 0 + for e in [element, separator]: + min_size, nat_size = e.get_preferred_size() + h += nat_size.height + + return h + + def set_default_size(self, width, height): + Gtk.Window.set_default_size(self, width, height) + + def show(self): + # show the window + Gtk.Window.show(self) + if mpl.rcParams["figure.raise_window"]: + if self._get_self(): + self.present() + else: + # If this is called by a callback early during init, + # self.window (a GtkWindow) may not have an associated + # low-level GdkWindow (on GTK3) or GdkSurface (on GTK4) yet, + # and present() would crash. + _api.warn_external("Cannot raise window yet to be setup") + + def destroy(self): + Gtk.Window.destroy(self) + + def set_fullscreen(self, fullscreen): + if fullscreen: + self.fullscreen() + else: + self.unfullscreen() + + def get_window_title(self): + return self.get_title() + + def set_window_title(self, title): + self.set_title(title) + + def resize(self, width, height): + Gtk.Window.resize(self, width, height) + + class _FigureManagerGTK(FigureManagerBase): """ Attributes @@ -135,51 +238,22 @@ class _FigureManagerGTK(FigureManagerBase): """ def __init__(self, canvas, num): - self._gtk_ver = gtk_ver = Gtk.get_major_version() - app = _create_application() - self.window = Gtk.Window() + self.window = self._window_class('Matplotlib Figure Manager') app.add_window(self.window) super().__init__(canvas, num) - if gtk_ver == 3: - self.window.set_wmclass("matplotlib", "Matplotlib") - icon_ext = "png" if sys.platform == "win32" else "svg" - self.window.set_icon_from_file( - str(cbook._get_data_path(f"images/matplotlib.{icon_ext}"))) - - self.vbox = Gtk.Box() - self.vbox.set_property("orientation", Gtk.Orientation.VERTICAL) - - if gtk_ver == 3: - self.window.add(self.vbox) - self.vbox.show() - self.canvas.show() - self.vbox.pack_start(self.canvas, True, True, 0) - elif gtk_ver == 4: - self.window.set_child(self.vbox) - self.vbox.prepend(self.canvas) - - # calculate size for window + self.window.add_element(self.canvas, 'center') w, h = self.canvas.get_width_height() - if self.toolbar is not None: - if gtk_ver == 3: - self.toolbar.show() - self.vbox.pack_end(self.toolbar, False, False, 0) - elif gtk_ver == 4: - sw = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER) - sw.set_child(self.toolbar) - self.vbox.append(sw) - min_size, nat_size = self.toolbar.get_preferred_size() - h += nat_size.height + if self.toolbar: + h += self.window.add_element(self.toolbar, 'south') # put in ScrolledWindow in GTK4? self.window.set_default_size(w, h) self._destroying = False - self.window.connect("destroy", lambda *args: Gcf.destroy(self)) - self.window.connect({3: "delete_event", 4: "close-request"}[gtk_ver], - lambda *args: Gcf.destroy(self)) + self.window.mpl_connect('window_destroy_event', lambda *args: Gcf.destroy(self)) + if mpl.is_interactive(): self.window.show() self.canvas.draw_idle() @@ -220,24 +294,9 @@ def show(self): # show the figure window self.window.show() self.canvas.draw() - if mpl.rcParams["figure.raise_window"]: - meth_name = {3: "get_window", 4: "get_surface"}[self._gtk_ver] - if getattr(self.window, meth_name)(): - self.window.present() - else: - # If this is called by a callback early during init, - # self.window (a GtkWindow) may not have an associated - # low-level GdkWindow (on GTK3) or GdkSurface (on GTK4) yet, - # and present() would crash. - _api.warn_external("Cannot raise window yet to be setup") def full_screen_toggle(self): - is_fullscreen = { - 3: lambda w: (w.get_window().get_state() - & Gdk.WindowState.FULLSCREEN), - 4: lambda w: w.is_fullscreen(), - }[self._gtk_ver] - if is_fullscreen(self.window): + if self.window.is_fullscreen(): self.window.unfullscreen() else: self.window.fullscreen() @@ -255,7 +314,7 @@ def resize(self, width, height): min_size, nat_size = self.toolbar.get_preferred_size() height += nat_size.height canvas_size = self.canvas.get_allocation() - if self._gtk_ver >= 4 or canvas_size.width == canvas_size.height == 1: + if canvas_size.width == canvas_size.height == 1: # A canvas size of (1, 1) cannot exist in most cases, because # window decorations would prevent such a small window. This call # must be before the window has been mapped and widgets have been diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 49d34f5794e4..2c2860e7b484 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -1,6 +1,7 @@ import functools import logging import os +import sys from pathlib import Path import matplotlib as mpl @@ -27,7 +28,7 @@ from . import _backend_gtk from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611 _BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK, - TimerGTK as TimerGTK3, + _WindowGTK, TimerGTK as TimerGTK3, ) @@ -41,6 +42,36 @@ def _mpl_to_gtk_cursor(mpl_cursor): _backend_gtk.mpl_to_gtk_cursor_name(mpl_cursor)) +class WindowGTK3(_WindowGTK): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.set_wmclass("matplotlib", "Matplotlib") + icon_ext = "png" if sys.platform == "win32" else "svg" + self.set_icon_from_file( + str(cbook._get_data_path(f"images/matplotlib.{icon_ext}"))) + + def _add_element(self, parent, child, appending, grow): + # Helper method to ease compatibility between GTK3 and GTK4 + if appending: + parent.pack_start(child, grow, grow, 0) + else: + parent.pack_end(child, grow, grow, 0) + + def set_child(self, *args, **kwargs): + self.add(*args, **kwargs) + + def _setup_signals(self): + self.connect('destroy', self.destroy_event) + self.connect('delete_event', self.destroy_event) + + def is_fullscreen(self): + return self.get_window().get_state() & Gdk.WindowState.FULLSCREEN + + def _get_self(self): + return self.get_window() + + class FigureCanvasGTK3(_FigureCanvasGTK, Gtk.DrawingArea): required_interactive_framework = "gtk3" manager_class = _api.classproperty(lambda cls: FigureManagerGTK3) @@ -574,6 +605,7 @@ def trigger(self, *args, **kwargs): class FigureManagerGTK3(_FigureManagerGTK): _toolbar2_class = NavigationToolbar2GTK3 _toolmanager_toolbar_class = ToolbarGTK3 + _window_class = WindowGTK3 @_BackendGTK.export diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 256a8ec9e864..b6e67225fede 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -26,10 +26,28 @@ from . import _backend_gtk from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611 _BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK, - TimerGTK as TimerGTK4, + _WindowGTK, TimerGTK as TimerGTK4, ) +class WindowGTK4(_WindowGTK): + def _add_element(self, parent, child, appending, grow): + if appending: + parent.append(child) + else: + parent.prepend(child) + + def _setup_signals(self): + self.connect('destroy', self.destroy_event) + self.connect('close-request', self.destroy_event) + + def resize(self, width, height): + Gtk.Window.set_default_size(self, width, height) + + def _get_self(self): + return self.get_surface() + + class FigureCanvasGTK4(_FigureCanvasGTK, Gtk.DrawingArea): required_interactive_framework = "gtk4" supports_blit = False @@ -593,6 +611,7 @@ def trigger(self, *args, **kwargs): class FigureManagerGTK4(_FigureManagerGTK): _toolbar2_class = NavigationToolbar2GTK4 _toolmanager_toolbar_class = ToolbarGTK4 + _window_class = WindowGTK4 @_BackendGTK.export diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index e4f60aac37a8..d2acc559ade0 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -330,6 +330,18 @@ def blocked(self, *, signal=None): self.callbacks = orig +class EventEmitter(object): + def __init__(self, **kwargs): + super(EventEmitter, self).__init__(**kwargs) # call next class on MRO + self._callbacks = CallbackRegistry() + + def mpl_connect(self, s, func): + return self._callbacks.connect(s, func) + + def mpl_disconnect(self, cid): + return self._callbacks.disconnect(cid) + + class silent_list(list): """ A list with a short ``repr()``.