diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index fd35b312835a..c677875b2db0 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -1393,6 +1393,28 @@ def set_mouseover(self, mouseover): ax._mouseover_set.discard(self) mouseover = property(get_mouseover, set_mouseover) # backcompat. + + def set_tooltip(self, tooltip): + """ + Set tooltip for the artist. + + Parameters + ---------- + tooltip : str, list, callable, or None + - If a string, the same tooltip is used for all data points. + - If a list, each element is used as tooltip for the corresponding data point. + - If a callable, it is called with the data point coordinates as arguments, + and should return the tooltip string. + - If None, no tooltip is shown. + """ + self._tooltip = tooltip + self.stale = True + + def get_tooltip(self): + """ + Return the tooltip for the artist. + """ + return getattr(self, '_tooltip', None) def _get_tightbbox_for_layout_only(obj, *args, **kwargs): diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index e480f8f29598..6d415b187158 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -1769,9 +1769,17 @@ def plot(self, *args, scalex=True, scaley=True, data=None, **kwargs): (``'green'``) or hex strings (``'#008000'``). """ kwargs = cbook.normalize_kwargs(kwargs, mlines.Line2D) + + # Extract tooltip parameter before creating lines + tooltip = kwargs.pop('tooltip', None) + lines = [*self._get_lines(self, *args, data=data, **kwargs)] for line in lines: self.add_line(line) + # Set tooltip if provided + if tooltip is not None: + line.set_tooltip(tooltip) + if scalex: self._request_autoscale_view("x") if scaley: diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 2158990f578a..a93fa2671b09 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1904,6 +1904,28 @@ def draw_idle(self, *args, **kwargs): with self._idle_draw_cntx(): self.draw(*args, **kwargs) + def show_tooltip(self, text, x, y, timeout=None): + """ + Display a tooltip with the given text at the position (x, y). + + Parameters + ---------- + text : str + The tooltip text containing arbitrary data. + x, y : float + The position of the tooltip in display coordinates. + timeout : float, optional + The timeout in seconds after which the tooltip should disappear. + If None, the tooltip will remain until explicitly hidden. + """ + pass + + def hide_tooltip(self): + """ + Hide the currently displayed tooltip. + """ + pass + @property def device_pixel_ratio(self): """ diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 5cde4866cad7..fbea938a1b72 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -15,6 +15,7 @@ from . import qt_compat from .qt_compat import ( QtCore, QtGui, QtWidgets, __version__, QT_API, _to_int, _isdeleted) +from matplotlib.backends.backend_qt import FigureCanvasQT # SPECIAL_KEYS are Qt::Key that do *not* return their Unicode name @@ -542,6 +543,64 @@ def _draw_rect_callback(painter): return self._draw_rect_callback = _draw_rect_callback self.update() + + def __init__(self, figure): + super().__init__(figure) + self._tooltip_timer = None + self._tooltip_label = None + + def show_tooltip(self, text, x, y, timeout=None): + """ + Show a tooltip on the canvas. + """ + from matplotlib.backends.qt_compat import QtWidgets, QtCore + + if self._tooltip_label is None: + self._tooltip_label = QtWidgets.QLabel(self) + self._tooltip_label.setStyleSheet(""" + background-color: rgba(255, 255, 245, 0.9); + border: 1px solid rgba(0, 0, 0, 0.5); + border-radius: 4px; + padding: 4px; + font-size: 9pt; + """) + self._tooltip_label.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) + self._tooltip_label.hide() + + # x, y are expected to be in display/pixel coordinates + # Set text and adjust size + self._tooltip_label.setText(text) + self._tooltip_label.adjustSize() + + # Position tooltip slightly offset from cursor + offset_x, offset_y = 10, 10 + self._tooltip_label.move(int(x + offset_x), int(y + offset_y)) + + # Show tooltip + self._tooltip_label.show() + self._tooltip_label.raise_() + + # Handle timeout (optional - removes tooltip automatically) + if self._tooltip_timer is not None: + self._tooltip_timer.stop() + self._tooltip_timer = None + + if timeout is not None: + self._tooltip_timer = QtCore.QTimer() + self._tooltip_timer.setSingleShot(True) + self._tooltip_timer.timeout.connect(self.hide_tooltip) + self._tooltip_timer.start(int(timeout * 1000)) + + def hide_tooltip(self): + """ + Hide the tooltip. + """ + if self._tooltip_label is not None: + self._tooltip_label.hide() + + if self._tooltip_timer is not None: + self._tooltip_timer.stop() + self._tooltip_timer = None class MainWindow(QtWidgets.QMainWindow): diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index bf4e2253324f..00267bd39c52 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -27,6 +27,7 @@ """ from contextlib import ExitStack +from matplotlib import lines import inspect import itertools import functools @@ -2650,6 +2651,7 @@ def __init__(self, self._axstack = _AxesStack() # track all figure Axes and current Axes self.clear() + self._init_tooltip_handling() def pick(self, mouseevent): if not self.canvas.widgetlock.locked(): @@ -3581,6 +3583,118 @@ def handler(event): self.canvas.draw() return clicks + + def _init_tooltip_handling(self): + """ + Initialize tooltip event handling. + """ + self._tooltip_cid = self.canvas.mpl_connect( + 'motion_notify_event', self._on_mouse_move_for_tooltip) + + def _on_mouse_move_for_tooltip(self, event): + """ + Handle mouse movement for tooltip display. + + This method is called when the mouse moves over the figure. + If the mouse is over an artist with tooltip data, the tooltip is shown. + Otherwise, the tooltip is hidden. + """ + if not event.inaxes: + self.canvas.hide_tooltip() + return + + # Get all artists under the cursor, from top to bottom + artists = [] + for ax in self.get_axes(): + if ax == event.inaxes: # Only check the current axes + # Get all artists in this axes, reversed to check top-most first + for artist in reversed(ax.get_children()): + if hasattr(artist, 'get_tooltip') and artist.get_visible(): + artists.append(artist) + + # Check if the mouse is over any artist with tooltip data + found_tooltip = False + for artist in artists: + tooltip = artist.get_tooltip() + if tooltip is None: + continue + + # Check if the mouse is over this artist + contains, info = artist.contains(event) + if not contains: + continue + + # Get tooltip text based on the type of tooltip + # if isinstance(artist, matplotlib.lines.Line2D) and 'ind' in info: + if isinstance(artist, lines.Line2D) and 'ind' in info: + # For Line2D objects + indices = info['ind'] + if not indices.size: + continue + + idx = indices[0] + xdata, ydata = artist.get_data() + + if callable(tooltip): + # Function tooltip + if idx < len(xdata) and idx < len(ydata): + text = tooltip(xdata[idx], ydata[idx]) + else: + continue + elif isinstance(tooltip, list): + # List tooltip + if idx < len(tooltip): + text = tooltip[idx] + else: + continue + else: + # String tooltip + text = str(tooltip) + + elif isinstance(artist, matplotlib.collections.PathCollection) and 'ind' in info: + # For scatter plots + indices = info['ind'] + if not indices.size: + continue + + idx = indices[0] + offsets = artist.get_offsets() + + if callable(tooltip): + # Function tooltip + if idx < len(offsets): + x, y = offsets[idx] + text = tooltip(x, y) + else: + continue + elif isinstance(tooltip, list): + # List tooltip + if idx < len(tooltip): + text = tooltip[idx] + else: + continue + else: + # String tooltip + text = str(tooltip) + + else: + # Generic artist + if callable(tooltip): + text = tooltip(event.xdata, event.ydata) + else: + text = str(tooltip) + + # Show the tooltip + bbox = self.bbox + fig_x = event.x / bbox.width + fig_y = event.y / bbox.height + self.canvas.show_tooltip(text, fig_x, fig_y) + found_tooltip = True + break + + # Hide tooltip if no artist with tooltip was found + if not found_tooltip: + self.canvas.hide_tooltip() def waitforbuttonpress(self, timeout=-1): """ diff --git a/test_tooltip.py b/test_tooltip.py new file mode 100644 index 000000000000..8b119da01b0e --- /dev/null +++ b/test_tooltip.py @@ -0,0 +1,55 @@ +# # ## Check if your tooltip data is being stored +# # import matplotlib.pyplot as plt +# # import numpy as np + +# # # Test if tooltip is being stored +# # x = np.linspace(0, 10, 5) +# # y = np.sin(x) + +# # line, = plt.plot(x, y, 'o-', tooltip=['Point A', 'Point B', 'Point C', 'Point D', 'Point E']) + +# # # Check if tooltip was stored +# # print("Tooltip stored:", line.get_tooltip()) +# # print("Line has tooltip method:", hasattr(line, 'get_tooltip')) + +# # # Not showing the plot yet, just testing the storage + + + + +# ## Test the backend +# import matplotlib.pyplot as plt +# import matplotlib +# import numpy as np + +# print("Backend:", matplotlib.get_backend()) + +# # Test with a simple plot and try hovering +# x = np.linspace(0, 10, 5) +# y = np.sin(x) + +# fig, ax = plt.subplots() +# line, = ax.plot(x, y, 'o-', tooltip=['Point A', 'Point B', 'Point C', 'Point D', 'Point E'], picker=5) + +# print("Tooltip stored:", line.get_tooltip()) +# print("Figure has tooltip handling:", hasattr(fig, '_on_mouse_move_for_tooltip')) + +# # Test if canvas has tooltip methods +# print("Canvas has show_tooltip:", hasattr(fig.canvas, 'show_tooltip')) +# print("Canvas has hide_tooltip:", hasattr(fig.canvas, 'hide_tooltip')) + +# plt.title('Try hovering over the points') +# plt.show() + + +# import matplotlib.pyplot as plt +# import numpy as np + +# # Test tooltips with console output +# x = np.linspace(0, 10, 5) +# y = np.sin(x) + +# plt.figure() +# plt.plot(x, y, 'o-', tooltip=['Point A', 'Point B', 'Point C', 'Point D', 'Point E']) +# plt.title('Move mouse over points - check console for tooltip messages') +# plt.show() \ No newline at end of file