Source code for spyder.api.widgets.menus
# -*- coding: utf-8 -*-
#
# Copyright © Spyder Project Contributors
# Licensed under the terms of the MIT License
# (see spyder/__init__.py for details)
"""
Spyder API menu widgets.
"""
# Standard library imports
import sys
from typing import Optional, Union, TypeVar
# Third party imports
import qstylizer.style
from qtpy.QtCore import QTimer
from qtpy.QtGui import QCursor
from qtpy.QtWidgets import QAction, QMenu, QProxyStyle, QStyle, QWidget
# Local imports
from spyder.api.fonts import SpyderFontType, SpyderFontsMixin
from spyder.utils.qthelpers import add_actions, set_menu_icons, SpyderAction
from spyder.utils.palette import SpyderPalette
from spyder.utils.stylesheet import AppStyle, MAC, WIN
# ---- Constants
# -----------------------------------------------------------------------------
MENU_SEPARATOR = None
# Generic type annotations
T = TypeVar('T', bound='SpyderMenu')
class OptionsMenuSections:
Top = 'top_section'
Bottom = 'bottom_section'
class PluginMainWidgetMenus:
Context = 'context_menu'
Options = 'options_menu'
# ---- Style
# -----------------------------------------------------------------------------
[docs]
class SpyderMenuProxyStyle(QProxyStyle):
"""Style adjustments that can only be done with a proxy style."""
[docs]
def pixelMetric(self, metric, option=None, widget=None):
if metric == QStyle.PM_SmallIconSize:
# Change icon size for menus.
# Taken from https://stackoverflow.com/a/42145885/438386
delta = -1 if MAC else (0 if WIN else 1)
return (
QProxyStyle.pixelMetric(self, metric, option, widget) + delta
)
return QProxyStyle.pixelMetric(self, metric, option, widget)
# ---- Widgets
# -----------------------------------------------------------------------------
[docs]
class SpyderMenu(QMenu, SpyderFontsMixin):
"""
A QMenu subclass to implement additional functionality for Spyder.
"""
MENUS = []
APP_MENU = False
HORIZONTAL_MARGIN_FOR_ITEMS = 2 * AppStyle.MarginSize
HORIZONTAL_PADDING_FOR_ITEMS = 3 * AppStyle.MarginSize
def __init__(
self,
parent: Optional[QWidget] = None,
menu_id: Optional[str] = None,
title: Optional[str] = None,
min_width: Optional[int] = None,
reposition: Optional[bool] = True,
):
"""
Create a menu for Spyder.
Parameters
----------
parent: QWidget or None
The menu's parent
menu_id: str
Unique str identifier for the menu.
title: str or None
Localized text string for the menu.
min_width: int or None
Minimum width for the menu.
reposition: bool, optional (default True)
Whether to vertically reposition the menu due to it's padding.
"""
self._parent = parent
self.menu_id = menu_id
self._title = title
self._reposition = reposition
self._sections = []
self._actions = []
self._actions_map = {}
self._unintroduced_actions = {}
self._after_sections = {}
self._dirty = False
self._is_shown = False
self._is_submenu = False
self._in_app_menu = False
if title is None:
super().__init__(parent)
else:
super().__init__(title, parent)
self.MENUS.append((parent, title, self))
# Set min width
if min_width is not None:
self.setMinimumWidth(min_width)
# Signals
self.aboutToShow.connect(self.render)
# Adjustmens for Mac
if sys.platform == 'darwin':
# Needed to enable the dynamic population of actions in app menus
# in the aboutToShow signal.
# See spyder-ide/spyder#14612
if self.APP_MENU:
self.addAction(QAction(self))
# Necessary to follow Mac's HIG for app menus.
self.aboutToShow.connect(self._set_icons)
# Style
self.css = self._generate_stylesheet()
self.setStyleSheet(self.css.toString())
style = SpyderMenuProxyStyle(None)
style.setParent(self)
self.setStyle(style)
# ---- Public API
# -------------------------------------------------------------------------
[docs]
def clear_actions(self):
"""
Remove actions from the menu (including custom references)
Returns
-------
None.
"""
self.clear()
self._sections = []
self._actions = []
self._actions_map = {}
self._unintroduced_actions = {}
self._after_sections = {}
[docs]
def add_action(self: T,
action: Union[SpyderAction, T],
section: Optional[str] = None,
before: Optional[str] = None,
before_section: Optional[str] = None,
check_before: bool = True,
omit_id: bool = False):
"""
Add action to a given menu section.
Parameters
----------
action: SpyderAction
The action to add.
section: str or None
The section id in which to insert the `action`.
before: str
Make the action appear before the given action identifier.
before_section: str or None
Make the item section (if provided) appear before another
given section.
check_before: bool
Check if the `before` action is part of the menu. This is
necessary to avoid an infinite recursion when adding
unintroduced actions with this method again.
omit_id: bool
If True, then the menu will check if the item to add declares an
id, False otherwise. This flag exists only for items added on
Spyder 4 plugins. Default: False
"""
item_id = None
if isinstance(action, SpyderAction) or hasattr(action, 'action_id'):
item_id = action.action_id
# This is necessary when we set a menu for `action`, e.g. for
# todo_list_action in EditorMainWidget.
if action.menu() and isinstance(action.menu(), SpyderMenu):
action.menu()._is_submenu = True
elif isinstance(action, SpyderMenu) or hasattr(action, 'menu_id'):
item_id = action.menu_id
action._is_submenu = True
if not omit_id and item_id is None and action is not None:
raise AttributeError(f'Item {action} must declare an id.')
if before is None:
self._actions.append((section, action))
else:
new_actions = []
added = False
before_item = self._actions_map.get(before, None)
for sec, act in self._actions:
if before_item is not None and act == before_item:
added = True
new_actions.append((section, action))
new_actions.append((sec, act))
# Actions can't be added to the menu if the `before` action is
# not part of it yet. That's why we need to save them in the
# `_unintroduced_actions` dict, so we can add them again when
# the menu is rendered.
if not added and check_before:
before_actions = self._unintroduced_actions.get(before, [])
before_actions.append((section, action))
self._unintroduced_actions[before] = before_actions
self._actions = new_actions
if section not in self._sections:
self._add_section(section, before_section)
# Track state of menu to avoid re-rendering if menu has not changed
self._dirty = True
self._actions_map[item_id] = action
def remove_action(self, item_id: str):
if item_id in self._actions_map:
action = self._actions_map.pop(item_id)
position = None
for i, (_, act) in enumerate(self._actions):
if act == action:
position = i
break
if position is not None:
self._actions.pop(position)
self._dirty = True
[docs]
def get_actions(self):
"""
Return a parsed list of menu actions.
Includes MENU_SEPARATOR taking into account the sections defined.
"""
actions = []
for section in self._sections:
for (sec, action) in self._actions:
if sec == section:
actions.append(action)
actions.append(MENU_SEPARATOR)
return actions
[docs]
def get_sections(self):
"""
Return a tuple of menu sections.
"""
return tuple(self._sections)
# ---- Private API
# -------------------------------------------------------------------------
def _add_missing_actions(self):
"""
Add actions that were not introduced to the menu because a `before`
action they require is not part of it.
"""
for before, actions in self._unintroduced_actions.items():
for section, action in actions:
self.add_action(
action,
section=section,
before=before,
check_before=False
)
self._unintroduced_actions = {}
[docs]
def render(self, force=False):
"""
Create the menu prior to showing it. This takes into account sections
and location of menus.
Parameters
----------
force: bool, optional
Whether to force rendering the menu.
"""
if self._dirty or force:
self.clear()
self._add_missing_actions()
actions = self.get_actions()
add_actions(self, actions)
self._set_icons()
self._dirty = False
def _add_section(self, section, before_section=None):
"""
Add a new section to the list of sections in this menu.
Parameters
----------
before_section: str or None
Make `section` appear before another one.
"""
inserted_before_other = False
if before_section is not None:
if before_section in self._sections:
# If before_section was already introduced, we simply need to
# insert the new section on its position, which will put it
# exactly behind before_section.
idx = self._sections.index(before_section)
self._sections.insert(idx, section)
inserted_before_other = True
else:
# If before_section hasn't been introduced yet, we know we need
# to insert it after section when it's finally added to the
# menu. So, we preserve that info in the _after_sections dict.
self._after_sections[before_section] = section
# Append section to the list of sections because we assume
# people build menus from top to bottom, i.e. they add its
# upper sections first.
self._sections.append(section)
else:
self._sections.append(section)
# Check if section should be inserted after another one, according to
# what we have in _after_sections.
after_section = self._after_sections.pop(section, None)
if after_section is not None:
if not inserted_before_other:
# Insert section to the right of after_section, if it was not
# inserted before another one.
if section in self._sections:
self._sections.remove(section)
index = self._sections.index(after_section)
self._sections.insert(index + 1, section)
else:
# If section was already inserted before another one, then we
# need to move after_section to its left instead.
if after_section in self._sections:
self._sections.remove(after_section)
idx = self._sections.index(section)
self._sections.insert(idx, after_section)
def _set_icons(self):
"""
Unset menu icons for app menus and set them for regular menus.
This is necessary only for Mac to follow its Human Interface
Guidelines (HIG), which don't recommend icons in app menus.
"""
if sys.platform == "darwin":
if self.APP_MENU or self._in_app_menu:
set_menu_icons(self, False, in_app_menu=True)
else:
set_menu_icons(self, True)
@classmethod
def _generate_stylesheet(cls):
"""Generate base stylesheet for menus."""
css = qstylizer.style.StyleSheet()
font = cls.get_font(SpyderFontType.Interface)
# Add padding and border to follow modern standards
css.QMenu.setValues(
# Only add top and bottom padding so that menu separators can go
# completely from the left to right border.
paddingTop=f'{2 * AppStyle.MarginSize}px',
paddingBottom=f'{2 * AppStyle.MarginSize}px',
# This uses the same color as the separator
border=f"1px solid {SpyderPalette.COLOR_BACKGROUND_6}"
)
# Set the right background color. This is the only way to do it!
css['QWidget:disabled QMenu'].setValues(
backgroundColor=SpyderPalette.COLOR_BACKGROUND_3,
)
# Add padding around separators to prevent that hovering on items hides
# them.
css["QMenu::separator"].setValues(
# Only add top and bottom margins so that the separators can go
# completely from the left to right border.
margin=f'{2 * AppStyle.MarginSize}px 0px',
)
# Set menu item properties
delta_top = 0 if (MAC or WIN) else 1
delta_bottom = 0 if MAC else (2 if WIN else 1)
css["QMenu::item"].setValues(
height='1.1em' if MAC else ('1.35em' if WIN else '1.25em'),
marginLeft=f'{cls.HORIZONTAL_MARGIN_FOR_ITEMS}px',
marginRight=f'{cls.HORIZONTAL_MARGIN_FOR_ITEMS}px',
paddingTop=f'{AppStyle.MarginSize + delta_top}px',
paddingBottom=f'{AppStyle.MarginSize + delta_bottom}px',
paddingLeft=f'{cls.HORIZONTAL_PADDING_FOR_ITEMS}px',
paddingRight=f'{cls.HORIZONTAL_PADDING_FOR_ITEMS}px',
fontFamily=font.family(),
fontSize=f'{font.pointSize()}pt',
backgroundColor='transparent'
)
# Set hover and pressed state of items
for state in ['selected', 'pressed']:
if state == 'selected':
bg_color = SpyderPalette.COLOR_BACKGROUND_4
else:
bg_color = SpyderPalette.COLOR_BACKGROUND_5
css[f"QMenu::item:{state}"].setValues(
backgroundColor=bg_color,
borderRadius=SpyderPalette.SIZE_BORDER_RADIUS
)
# Set disabled state of items
for state in ['disabled', 'selected:disabled']:
css[f"QMenu::item:{state}"].setValues(
color=SpyderPalette.COLOR_DISABLED,
backgroundColor="transparent"
)
return css
def __str__(self):
return f"SpyderMenu('{self.menu_id}')"
def __repr__(self):
return f"SpyderMenu('{self.menu_id}')"
def _adjust_menu_position(self):
"""Menu position adjustment logic to follow custom style."""
if not self._is_shown:
# Reposition submenus vertically due to padding and border
if self._reposition and self._is_submenu:
self.move(
self.pos().x(),
# Current vertical pos - padding - border
self.pos().y() - 2 * AppStyle.MarginSize - 1
)
self._is_shown = True
# Reposition menus horizontally due to border
if self.APP_MENU:
delta_x = 0 if MAC else 3
else:
if QCursor().pos().x() - self.pos().x() < 40:
# If the difference between the current cursor x position and
# the menu one is small, it means the menu will be shown to the
# right, so we need to move it in that direction.
delta_x = 1
else:
# This happens when the menu is shown to the left.
delta_x = -1
self.move(self.pos().x() + delta_x, self.pos().y())
# ---- Qt methods
# -------------------------------------------------------------------------
[docs]
def showEvent(self, event):
"""Call adjustments when the menu is going to be shown."""
# To prevent race conditions which can cause partially showing a menu
# (as in spyder-ide/spyder#22266), we use a timer to queue the move
# related events after the menu is shown.
# For more info you can check:
# * https://forum.qt.io/topic/23381/showevent-not-working/3
# * https://stackoverflow.com/a/49351518
QTimer.singleShot(0, self._adjust_menu_position)
[docs]
class PluginMainWidgetOptionsMenu(SpyderMenu):
"""
Options menu for PluginMainWidget.
"""
[docs]
def render(self):
"""Render the menu's bottom section as expected."""
if self._dirty:
self.clear()
self._add_missing_actions()
bottom = OptionsMenuSections.Bottom
actions = []
for section in self._sections:
for (sec, action) in self._actions:
if sec == section and sec != bottom:
actions.append(action)
actions.append(MENU_SEPARATOR)
# Add bottom actions
for (sec, action) in self._actions:
if sec == bottom:
actions.append(action)
add_actions(self, actions)
self._set_icons()
self._dirty = False