Skip to content
Open
34 changes: 34 additions & 0 deletions doc/api/next_api_changes/behavior/31176-AA.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
Raising a figure window no longer steals keyboard focus by default
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Raising a figure window and giving it keyboard focus are now treated as two
independent operations, with a consistent default across all GUI backends:
raising a window no longer transfers keyboard focus unless explicitly
requested.

Previously this behavior was backend-dependent. Some backends (e.g. Qt and
GTK) activated the window and stole keyboard focus from the active application
when :rc:`figure.raise_window` was set, while others (e.g. macOS) only raised
the window in the stacking order. As a result, the same code could behave
differently after merely switching backends.

Now, both the new `.FigureManagerBase.raise_window` method and the raise
triggered by `~.pyplot.show` / :rc:`figure.raise_window` raise the window
without stealing focus. To additionally activate the window and give it
keyboard focus, pass ``with_focus=True``::

fig.canvas.manager.raise_window(with_focus=True)

On backends that previously stole focus (e.g. Qt and GTK) this restores the
old behavior; on backends that never did (e.g. macOS) it enables focusing the
window as a new capability.

This is a soft change: in the worst case a window that used to grab focus no
longer does so, and any code relying on that can opt back in with
``with_focus=True``.

The behavior is best-effort and platform-dependent. On some toolkits raising
and focusing cannot be fully separated (for example the GTK4 backend, which
can only raise via ``present()`` and may therefore still transfer focus), and
some window managers (notably Wayland compositors) may ignore or modify raise
and focus requests entirely.
20 changes: 20 additions & 0 deletions doc/release/next_whats_new/raise_window.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Raise a figure window on demand with ``raise_window``
-----------------------------------------------------

The figure manager now exposes a public ``raise_window`` method, letting you
bring a figure window to the front at any point in your program rather than
only as a side effect of `~.pyplot.show` and :rc:`figure.raise_window`::

fig.canvas.manager.raise_window()

By default, the window is raised in the stacking order *without* stealing
keyboard focus from the currently active application. This is convenient in
interactive workflows (for example from an IPython session), where you want to
glance at a figure without leaving your terminal. Pass ``with_focus=True`` to
additionally activate the window and give it keyboard focus::

fig.canvas.manager.raise_window(with_focus=True)

Raising and focusing are now treated as independent operations with a
consistent default across the GUI backends, so code that raises a window
behaves the same way regardless of which backend is in use.
25 changes: 25 additions & 0 deletions lib/matplotlib/backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2882,6 +2882,31 @@ def destroy(self):
def full_screen_toggle(self):
pass

def raise_window(self, *, with_focus=False):
"""
For GUI backends, raise the figure window to the top.

Parameters
----------
with_focus : bool, default: False
Whether to also give the window keyboard focus (activate it).
If False (the default), the window is raised in the stacking
order without stealing keyboard focus from the currently active
application. If True, the window is additionally activated and
given keyboard focus.

Notes
-----
Raising and focusing are treated as independent operations, with a
consistent default across backends: raising never steals focus
unless ``with_focus=True`` is passed.

This is a best-effort operation. Some window managers (notably
Wayland compositors) may ignore or modify raise and focus requests,
so the exact behavior is platform-dependent.
"""
pass

def resize(self, w, h):
"""For GUI backends, resize the window (in physical pixels)."""

Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backend_bases.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ class FigureManagerBase:
def show(self) -> None: ...
def destroy(self) -> None: ...
def full_screen_toggle(self) -> None: ...
def raise_window(self, *, with_focus: bool = ...) -> None: ...
def resize(self, w: int, h: int) -> None: ...
def get_window_title(self) -> str: ...
def set_window_title(self, title: str) -> None: ...
Expand Down
48 changes: 39 additions & 9 deletions lib/matplotlib/backends/_backend_gtk.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,15 +221,45 @@ def show(self):
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")
self.raise_window()

def raise_window(self, *, with_focus=False):
# docstring inherited
#
# Known limitation on macOS: there GTK runs on the Quartz backend, and
# the present()/raise_() calls below do not lift the window above other
# applications or change focus (macOS only does that for the active
# application). Implementing it would require getting the native
# NSWindow from GDK -- gdk_quartz_window_get_nswindow() on GTK3, and
# there is no clear public way to do it on GTK4 -- and then calling
# orderFrontRegardless/activate on it, as the Qt, Tk and wx backends do.
# GTK is primarily a Linux backend and is rarely used on macOS, so this
# is left unimplemented and documented as a known limitation. The calls
# below work on GTK's usual platforms (X11 and, best-effort, Wayland).
meth_name = {3: "get_window", 4: "get_surface"}[self._gtk_ver]
surface = getattr(self.window, meth_name)()
if not surface:
# 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()/raise_() would crash.
_api.warn_external("Cannot raise window yet to be setup")
return
if with_focus:
# present() raises the window and gives it keyboard focus. On
# Wayland this goes through the xdg-activation protocol and is
# best-effort (the compositor may ignore it without a token).
self.window.present()
elif self._gtk_ver == 3:
# GTK3 exposes a low-level raise that only changes the stacking
# order, without transferring keyboard focus (on X11; on Wayland
# the compositor controls stacking, so this is a no-op).
surface.raise_()
else:
# GTK4 (GdkSurface) has no way to raise without activating, so fall
# back to present(). This may transfer focus; raising and focusing
# cannot be separated on this toolkit.
self.window.present()

def full_screen_toggle(self):
is_fullscreen = {
Expand Down
17 changes: 15 additions & 2 deletions lib/matplotlib/backends/_backend_tk.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,10 +615,23 @@ def destroy(*args):
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.raise_window()
self._shown = True

def raise_window(self, *, with_focus=False):
# docstring inherited
if not with_focus and sys.platform == "darwin":
# On macOS the -topmost toggle does not lift the window above other
# applications; use the native focus-free raise instead.
from ._macos_window import MacOSWindow
MacOSWindow.from_tk_drawable(self.window.winfo_id()).raise_window(
with_focus=False)
return
self.window.attributes('-topmost', 1)
self.window.attributes('-topmost', 0)
if with_focus:
self.window.focus_force()

def destroy(self, *args):
if self.canvas._idle_draw_id:
self.canvas._tkcanvas.after_cancel(self.canvas._idle_draw_id)
Expand Down
81 changes: 81 additions & 0 deletions lib/matplotlib/backends/_macos_window.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""
A wrapper around a native macOS NSWindow, for use by the GUI backends.

On macOS a backgrounded application cannot lift a window above the foreground
application, or take keyboard focus, using a toolkit's normal raise call. The
native NSWindow methods do work. The Qt, Tk and wx backends obtain their
NSWindow in different ways (see the constructors below) and then share the
operations on `MacOSWindow`. Keeping it a class makes it straightforward to add
further NSWindow operations later (e.g. window level or frame queries).
"""
import ctypes
import ctypes.util
import functools


@functools.cache
def _objc():
objc = ctypes.CDLL(ctypes.util.find_library("objc"))
objc.sel_registerName.restype = ctypes.c_void_p
objc.sel_registerName.argtypes = [ctypes.c_char_p]
objc.objc_getClass.restype = ctypes.c_void_p
objc.objc_getClass.argtypes = [ctypes.c_char_p]
return objc


def _msg(receiver, selector, *args, argtypes=()):
# Send an Objective-C message. objc_msgSend is variadic, so a prototype is
# built per call signature; restype/argtypes MUST be pointer-width or 64-bit
# object pointers are truncated and crash on arm64.
objc = _objc()
send = ctypes.CFUNCTYPE(
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, *argtypes)(
("objc_msgSend", objc))
return send(ctypes.c_void_p(receiver), objc.sel_registerName(selector), *args)


class MacOSWindow:
"""A native macOS NSWindow, wrapped for use by the GUI backends."""

def __init__(self, nswindow):
# *nswindow* is the NSWindow pointer (int), or None/0 if unavailable.
self._nswindow = nswindow or None

@classmethod
def from_nsview(cls, nsview):
"""Build from an NSView pointer (Qt's ``winId()``, wx's ``GetHandle()``)."""
nswindow = _msg(int(nsview), b"window") if nsview else None
return cls(nswindow)

@classmethod
def from_tk_drawable(cls, drawable):
"""Build from a Tk drawable (a window's ``winfo_id()``)."""
try:
get = ctypes.CDLL(None).Tk_MacOSXGetNSWindowForDrawable
except (OSError, AttributeError):
return cls(None)
get.restype = ctypes.c_void_p
get.argtypes = [ctypes.c_void_p]
return cls(get(ctypes.c_void_p(int(drawable))))

def raise_window(self, *, with_focus):
"""
Raise the window to the front.

If *with_focus*, also bring the application forward and give the window
keyboard focus; otherwise raise it without taking focus.
"""
if not self._nswindow:
return
if with_focus:
# activateIgnoringOtherApps: is deprecated but used deliberately:
# the modern -activate is cooperative and will not foreground a
# background app when called programmatically (see backend_macosx).
nsapp = _msg(_objc().objc_getClass(b"NSApplication"),
b"sharedApplication")
_msg(nsapp, b"activateIgnoringOtherApps:", True,
argtypes=(ctypes.c_bool,))
_msg(self._nswindow, b"makeKeyAndOrderFront:", None,
argtypes=(ctypes.c_void_p,))
else:
_msg(self._nswindow, b"orderFrontRegardless")
6 changes: 5 additions & 1 deletion lib/matplotlib/backends/backend_macosx.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,11 @@ def show(self):
self._show()
self._shown = True
if mpl.rcParams["figure.raise_window"]:
self._raise()
self.raise_window()

def raise_window(self, *, with_focus=False):
# docstring inherited
self._raise(with_focus=with_focus)


@_Backend.export
Expand Down
13 changes: 13 additions & 0 deletions lib/matplotlib/backends/backend_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,8 +664,21 @@ def show(self):
self.window._destroying = False
self.window.show()
if mpl.rcParams['figure.raise_window']:
self.raise_window()

def raise_window(self, *, with_focus=False):
# docstring inherited
if with_focus:
self.window.activateWindow()
self.window.raise_()
elif sys.platform == "darwin":
# On macOS, Qt's raise_() also activates the app (stealing focus),
# so raise the native NSWindow without focus instead.
from ._macos_window import MacOSWindow
MacOSWindow.from_nsview(self.window.winId()).raise_window(
with_focus=False)
else:
self.window.raise_()

def destroy(self, *args):
# check for qApp first, as PySide deletes it in its atexit handler
Expand Down
17 changes: 16 additions & 1 deletion lib/matplotlib/backends/backend_wx.py
Original file line number Diff line number Diff line change
Expand Up @@ -1002,7 +1002,22 @@ def show(self):
self.frame.Show()
self.canvas.draw()
if mpl.rcParams['figure.raise_window']:
self.frame.Raise()
self.raise_window()

def raise_window(self, *, with_focus=False):
# docstring inherited
if sys.platform == "darwin":
# wx's Raise()/SetFocus() don't lift above other apps or activate
# on macOS; use the native NSWindow calls instead.
from ._macos_window import MacOSWindow
MacOSWindow.from_nsview(self.frame.GetHandle()).raise_window(
with_focus=with_focus)
return
# Raise() only changes the stacking order (it does not activate the
# window or take keyboard focus); SetFocus() additionally grabs focus.
self.frame.Raise()
if with_focus:
self.frame.SetFocus()

def destroy(self, *args):
# docstring inherited
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ python_sources = [
'backend_gtk4agg.py',
'backend_gtk4cairo.py',
'backend_macosx.py',
'_macos_window.py',
'backend_mixed.py',
'backend_nbagg.py',
'_backend_pdf_ps.py',
Expand Down
12 changes: 10 additions & 2 deletions lib/matplotlib/tests/test_backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

from matplotlib import path, transforms
from matplotlib.backend_bases import (
FigureCanvasBase, KeyEvent, LocationEvent, MouseButton, MouseEvent,
NavigationToolbar2, RendererBase)
FigureCanvasBase, FigureManagerBase, KeyEvent, LocationEvent, MouseButton,
MouseEvent, NavigationToolbar2, RendererBase)
from matplotlib.backend_tools import RubberbandBase
from matplotlib.figure import Figure
from matplotlib.testing._markers import needs_pgf_xelatex
Expand Down Expand Up @@ -63,6 +63,14 @@ def test_canvas_ctor():
assert isinstance(FigureCanvasBase().figure, Figure)


def test_figure_manager_base_raise_window_noop():
canvas = FigureCanvasBase(Figure())
manager = FigureManagerBase(canvas, 1)
assert manager.raise_window() is None
assert manager.raise_window(with_focus=True) is None
assert manager.raise_window(with_focus=False) is None


def test_get_default_filename():
fig = plt.figure()
assert fig.canvas.get_default_filename() == "Figure_1.png"
Expand Down
12 changes: 11 additions & 1 deletion lib/matplotlib/tests/test_backends_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,18 @@ def check_alt_backend(alt_backend):
import asyncio
asyncio.set_event_loop(asyncio.new_event_loop())

def _quit(*args):
# Smoke-test raise_window on a realized window: every backend must
# accept with_focus and run all three forms without error. (The actual
# raise/focus behaviour is platform-dependent and verified manually.)
manager = fig.canvas.manager
manager.raise_window()
manager.raise_window(with_focus=False)
manager.raise_window(with_focus=True)
KeyEvent("key_press_event", fig.canvas, "q")._process()

timer = fig.canvas.new_timer(1.) # Test that floats are cast to int.
timer.add_callback(KeyEvent("key_press_event", fig.canvas, "q")._process)
timer.add_callback(_quit)
# Trigger quitting upon draw.
fig.canvas.mpl_connect("draw_event", lambda event: timer.start())
fig.canvas.mpl_connect("close_event", print)
Expand Down
Loading
Loading