Source code for spyder.api.widgets.toolbars

# -----------------------------------------------------------------------------
# Copyright (c) 2020- Spyder Project Contributors
#
# Released under the terms of the MIT License
# (see LICENSE.txt in the project root directory for details)
# -----------------------------------------------------------------------------

"""
Spyder API toolbar widgets.
"""

from __future__ import annotations

# Standard library imports
import os
import sys
import uuid
from collections import OrderedDict
from typing import Literal, Union, TYPE_CHECKING

if sys.version_info < (3, 10):
    from typing_extensions import TypeAlias
else:
    from typing import TypeAlias  # noqa: ICN003

# Third part imports
from qtpy.QtCore import QEvent, QObject, QSize, Qt, Signal
from qtpy.QtWidgets import (
    QAction,
    QProxyStyle,
    QStyle,
    QToolBar,
    QToolButton,
    QWidget,
)

# Local imports
import spyder.utils.qthelpers  # For fully-qualified SpyderAction in type alias
from spyder.api.exceptions import SpyderAPIError
from spyder.api.translations import _
from spyder.api.widgets.menus import SpyderMenu, SpyderMenuProxyStyle
from spyder.utils.icon_manager import ima
from spyder.utils.qthelpers import SpyderAction
from spyder.utils.stylesheet import (
    APP_TOOLBAR_STYLESHEET,
    PANES_TOOLBAR_STYLESHEET,
)

if TYPE_CHECKING:
    from qtpy.QtWidgets import QMainWindow, QStyleOption


# Generic type aliases
ToolbarItem: TypeAlias = Union[spyder.utils.qthelpers.SpyderAction, QWidget]
"""Type alias for the set of supported objects that can be toolbar items."""

ToolbarItemEntry: TypeAlias = tuple[
    ToolbarItem, Union[str, None], Union[str, None], Union[str, None]
]
"""Type alias for the full tuple entry in the list of toolbar items."""


# ---- Constants
# ----------------------------------------------------------------------------
[docs] class ToolbarLocation: """Pseudo-enum listing possible locations for a toolbar.""" Top: Qt.ToolBarArea = Qt.TopToolBarArea """Toolbar at the top of the layout.""" Bottom: Qt.ToolBarArea = Qt.BottomToolBarArea """Toolbar at the bottom of the layout."""
# ---- Event filters # ---------------------------------------------------------------------------- class _ToolTipFilter(QObject): """ Filter tooltip events on toolbar buttons. """ def eventFilter(self, obj: QObject, event: QEvent) -> bool: """ Filter tooltip events on toolbar buttons. Parameters ---------- obj : QObject The object receiving the event. event : QEvent The event object. Returns ------- bool ``True`` the event should be filtered out, ``False`` otherwise. """ event_type = event.type() action = obj.defaultAction() if isinstance(obj, QToolButton) else None if event_type == QEvent.ToolTip and action is not None: if action.tip is None: return action.text_beside_icon return QObject.eventFilter(self, obj, event) # ---- Styles # ----------------------------------------------------------------------------
[docs] class ToolbarStyle(QProxyStyle): """Proxy style class to control the style of Spyder toolbars. .. deprecated:: 6.2 This class will be renamed to the private :class:`!_ToolbarStyle` in Spyder 6.2, while the current public name will become an alias raising a :exc:`DeprecationWarning` on use, and removed in 7.0. It was never intended to be used directly by plugins, and its functionality is automatically inherited by using the appropriate :class:`ApplicationToolbar` and :class:`MainWidgetToolbar` classes. """ TYPE: Literal["Application"] | Literal["MainWidget"] | None = None """ The toolbar type; must be either "Application" or "MainWidget". """
[docs] def pixelMetric( self, pm: QStyle.PixelMetric, option: QStyleOption, widget: QWidget ) -> int: """ Adjust size of toolbar extension button (in pixels). From `Stack Overflow <https://stackoverflow.com/a/27042352/438386>`__. This is a callback intended to be called internally by Qt. Parameters ---------- pm : QStyle.PixelMetric The pixel metric to calculate. option : QStyleOption | None, optional The current style options, or ``None`` (default). widget : QWidget | None, optional The widget the pixel metric will be used for, or ``None`` (default). Returns ------- int The resulting pixel metric value, used internally by Qt. Raises ------ SpyderAPIError If :attr:`TYPE` is not ``"Application"`` or ``"MainWidget"``, as then this style would do nothing. """ # Important: These values need to be updated in case we change the size # of our toolbar buttons in utils/stylesheet.py. That's because Qt only # allow to set them in pixels here, not em's. if pm == QStyle.PM_ToolBarExtensionExtent: if self.TYPE == "Application": if os.name == "nt": return 40 elif sys.platform == "darwin": return 54 else: return 57 elif self.TYPE == "MainWidget": if os.name == "nt": return 36 elif sys.platform == "darwin": return 42 else: return 44 else: raise SpyderAPIError( "Toolbar style must be 'Application' or 'MainWidget', not" f" {self.TYPE!r}" ) return super().pixelMetric(pm, option, widget)
# ---- Toolbars # ----------------------------------------------------------------------------
[docs] class SpyderToolbar(QToolBar): """ This class provides toolbars with some predefined functionality. .. caution:: This class isn't intended to be used directly; use its subclasses :class:`ApplicationToolbar` and :class:`MainWidgetToolbar` instead. """ sig_is_rendered: Signal = Signal() """ Signal to let other objects know that the toolbar is now rendered. """
[docs] def __init__(self, parent: QWidget | None, title: str) -> None: """ Create a new toolbar object. Parameters ---------- parent : QWidget | None The parent widget of this one, or ``None``. title : str The localized title of this toolbar, to display in the UI. Returns ------- None """ super().__init__(parent=parent) # Attributes self._title = title self._section_items = OrderedDict() self._item_map: dict[str, ToolbarItem] = {} self._pending_items: dict[str, list[ToolbarItemEntry]] = {} self._default_section = "default_section" self._filter = None self.setWindowTitle(title) # Set attributes for extension button. # From https://stackoverflow.com/a/55412455/438386 ext_button = self.findChild(QToolButton, "qt_toolbar_ext_button") ext_button.setIcon(ima.icon("toolbar_ext_button")) ext_button.setToolTip(_("More")) # Set style for extension button menu (not all extension buttons have # it). if ext_button.menu(): ext_button.menu().setStyleSheet( SpyderMenu._generate_stylesheet().toString() ) ext_button_menu_style = SpyderMenuProxyStyle(None) ext_button_menu_style.setParent(self) ext_button.menu().setStyle(ext_button_menu_style)
[docs] def add_item( self, action_or_widget: ToolbarItem, section: str | None = None, before: str | None = None, before_section: str | None = None, omit_id: bool = False, ) -> None: """ Add action or widget item to the given toolbar ``section``. Parameters ---------- action_or_widget: ToolbarItem The item to add to the toolbar. section: str | None, optional The section id in which to insert the ``action_or_widget``, or ``None`` (default) for no section. before: str | None, optional Make the ``action_or_widget`` appear before the action with the identifier ``before``. If ``None`` (default), add it to the end. If ``before`` is not ``None``, ``before_section`` will be ignored. before_section : str | None, optional Make the ``section`` appear prior to ``before_section``. If ``None`` (the default), add the section to the end. If you provide a ``before`` action, the new action will be placed before this one, so the section option will be ignored, since the action will now be placed in the same section as the ``before`` action. omit_id: bool, optional If ``False``, the default, then the toolbar will check if ``action.action_id`` exists and is set to a string, and raise an :exc:`~spyder.api.exceptions.SpyderAPIError` if either is not the case. If ``True``, it will add the ``action_or_widget`` anyway. Returns ------- None Raises ------ SpyderAPIError If ``omit_id`` is ``False`` (the default) and ``action_or_widget.action_id`` does not exist or is not set to a string. """ item_id = None if isinstance(action_or_widget, SpyderAction) or hasattr( action_or_widget, "action_id" ): item_id = action_or_widget.action_id elif hasattr(action_or_widget, "ID"): item_id = action_or_widget.ID if not omit_id and item_id is None and action_or_widget is not None: raise SpyderAPIError( f"Item {action_or_widget} must declare an ID attribute." ) if before is not None: if before not in self._item_map: before_pending_items = self._pending_items.get(before, []) before_pending_items.append( (action_or_widget, section, before, before_section) ) self._pending_items[before] = before_pending_items return else: before = self._item_map[before] if section is None: section = self._default_section action_or_widget._section = section if before is not None: if section == self._default_section: action_or_widget._section = before._section section = before._section if section not in self._section_items: self._section_items[section] = [action_or_widget] else: if before is not None: new_actions_or_widgets = [] for act_or_wid in self._section_items[section]: if act_or_wid == before: new_actions_or_widgets.append(action_or_widget) new_actions_or_widgets.append(act_or_wid) self._section_items[section] = new_actions_or_widgets else: self._section_items[section].append(action_or_widget) if ( before_section is not None and before_section in self._section_items ): new_sections_keys = [] for sec in self._section_items.keys(): if sec == before_section: new_sections_keys.append(section) if sec != section: new_sections_keys.append(sec) self._section_items = OrderedDict( (section_key, self._section_items[section_key]) for section_key in new_sections_keys ) if item_id is not None: self._item_map[item_id] = action_or_widget if item_id in self._pending_items: item_pending = self._pending_items.pop(item_id) for item, section, before, before_section in item_pending: self.add_item( item, section=section, before=before, before_section=before_section, )
[docs] def remove_item(self, item_id: str) -> None: """ Remove the toolbar item with the given string identifier. Parameters ---------- item_id : str The string identifier of the toolbar item to remove. Returns ------- None """ try: item = self._item_map.pop(item_id) for section in list(self._section_items.keys()): section_items = self._section_items[section] if item in section_items: section_items.remove(item) if len(section_items) == 0: self._section_items.pop(section) self.clear() self.render() except KeyError: pass
[docs] def render(self) -> None: """ Render the toolbar taking into account sections and locations. Returns ------- None """ sec_items = [] for sec, items in self._section_items.items(): for item in items: sec_items.append([sec, item]) sep = QAction(self) sep.setSeparator(True) sec_items.append((None, sep)) if sec_items: sec_items.pop() for sec, item in sec_items: if isinstance(item, QAction): add_method = super().addAction else: add_method = super().addWidget add_method(item) if isinstance(item, QAction): widget = self.widgetForAction(item) if self._filter is not None: widget.installEventFilter(self._filter) text_beside_icon = getattr(item, "text_beside_icon", False) if text_beside_icon: widget.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) if item.isCheckable(): widget.setCheckable(True) self.sig_is_rendered.emit()
[docs] class ApplicationToolbar(SpyderToolbar): """ A Spyder main application toolbar. These toolbars are placed above all Spyder dockable plugins in the interface. """ ID: str | None = None """ Unique string toolbar identifier. This is used by Qt to be able to save and restore the state of widgets. """
[docs] def __init__( self, parent: QMainWindow, toolbar_id: str, title: str ) -> None: """ Create a main Spyder application toolbar. Parameters ---------- parent : QMainWindow The parent main window of this toolbar. toolbar_id : str The unique string identifier of this toolbar. title : str The localized name of this toolbar, displayed in the interface. Returns ------- None """ super().__init__(parent=parent, title=title) self.ID = toolbar_id self._style = ToolbarStyle(None) self._style.TYPE = "Application" self._style.setParent(self) self.setStyle(self._style) self.setStyleSheet(str(APP_TOOLBAR_STYLESHEET))
[docs] def __str__(self) -> str: """ Output this toolbar's class name and identifier as a string. Returns ------- str The toolbar's class name and string identifier, in the format :file:``ApplicationToolbar({TOOLBAR_ID})``. """ return f"ApplicationToolbar('{self.ID}')"
[docs] def __repr__(self) -> str: """ Output this toolbar's class name and identifier as a string. Returns ------- str The menu's class name and string identifier, in the format :file:``ApplicationToolbar({TOOLBAR_ID})``. """ return f"ApplicationToolbar('{self.ID}')"
[docs] class MainWidgetToolbar(SpyderToolbar): """ A Spyder dockable plugin toolbar. This is used by dockable plugins to have their own toolbars. """ ID: str | None = None """ Unique string toolbar identifier. """
[docs] def __init__( self, parent: QWidget | None = None, title: str | None = None ) -> None: """ Create a new toolbar. Parameters ---------- parent : QWidget | None, optional The parent widget of this one, or ``None`` (default). title : str | None, optional The localized title of this toolbar, or ``None`` (default) for no title. Returns ------- None """ super().__init__(parent, title=title or "") self._icon_size = QSize(16, 16) # Setup self.setObjectName( "main_widget_toolbar_{}".format(str(uuid.uuid4())[:8]) ) self.setFloatable(False) self.setMovable(False) self.setContextMenuPolicy(Qt.PreventContextMenu) self.setIconSize(self._icon_size) self._style = ToolbarStyle(None) self._style.TYPE = "MainWidget" self._style.setParent(self) self.setStyle(self._style) self.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET)) self._filter = _ToolTipFilter()
[docs] def set_icon_size(self, icon_size: QSize) -> None: """ Set the icon size for this toolbar. Parameters ---------- icon_size : QSize The icon size to set. Returns ------- None """ self._icon_size = icon_size self.setIconSize(icon_size)