# -*- coding: utf-8 -*-
#
# Copyright © Spyder Project Contributors
# Licensed under the terms of the MIT License
# (see spyder/__init__.py for details)
"""
Spyder combobox widgets.
Use these widgets for any combobox you want to add to Spyder.
"""
# Standard library imports
import sys
# Third-party imports
import qstylizer.style
from qtpy.QtCore import QSize, Qt, Signal
from qtpy.QtGui import QColor, QPainter, QFontMetrics
from qtpy.QtWidgets import (
QComboBox,
QFontComboBox,
QFrame,
QLineEdit,
QListView,
QProxyStyle,
QStyle,
QStyledItemDelegate,
QStyleOptionFrame,
)
from superqt.utils import qdebounced
# Local imports
from spyder.utils.palette import SpyderPalette
from spyder.utils.stylesheet import AppStyle, WIN
class _SpyderComboBoxProxyStyle(QProxyStyle):
"""Style proxy to adjust qdarkstyle issues."""
def styleHint(self, hint, option=None, widget=None, returnData=None):
if hint == QStyle.SH_ComboBox_Popup:
# Disable combobox popup top & bottom areas.
# See spyder-ide/spyder#9682.
# Taken from https://stackoverflow.com/a/21019371
return 0
return QProxyStyle.styleHint(self, hint, option, widget, returnData)
class _SpyderComboBoxDelegate(QStyledItemDelegate):
"""
Delegate to make separators color follow our theme.
Adapted from https://stackoverflow.com/a/33464045/438386
"""
def __init__(self, parent, elide_mode=None):
super().__init__(parent)
self._elide_mode = elide_mode
def paint(self, painter, option, index):
data = index.data(Qt.AccessibleDescriptionRole)
if data and data == "separator":
painter.setPen(QColor(SpyderPalette.COLOR_BACKGROUND_6))
painter.drawLine(
option.rect.left() + AppStyle.MarginSize,
option.rect.center().y(),
option.rect.right() - AppStyle.MarginSize,
option.rect.center().y()
)
return
if self._elide_mode is not None:
option.textElideMode = self._elide_mode
super().paint(painter, option, index)
def sizeHint(self, option, index):
data = index.data(Qt.AccessibleDescriptionRole)
if data and data == "separator":
return QSize(0, 3 * AppStyle.MarginSize)
return super().sizeHint(option, index)
class _SpyderComboBoxLineEdit(QLineEdit):
"""Lineedit used for comboboxes."""
sig_mouse_clicked = Signal()
def __init__(self, parent, editable, elide_mode=None):
super().__init__(parent)
self._editable = editable
self._elide_mode = elide_mode
self._focus_in = False
# Fix style issues
css = qstylizer.style.StyleSheet()
css.QLineEdit.setValues(
# These are necessary on Windows to prevent some ugly visual
# glitches.
backgroundColor="transparent",
border="none",
padding="0px",
# Make text look centered for short comboboxes
paddingRight=f"-{3 if WIN else 2}px"
)
self.setStyleSheet(css.toString())
def mouseReleaseEvent(self, event):
if not self._editable:
# Emit a signal to display the popup afterwards
self.sig_mouse_clicked.emit()
super().mouseReleaseEvent(event)
def mouseDoubleClickEvent(self, event):
if not self._editable:
# Avoid selecting the lineedit text with double clicks
pass
else:
super().mouseDoubleClickEvent(event)
def focusInEvent(self, event):
self._focus_in = True
super().focusInEvent(event)
def focusOutEvent(self, event):
self._focus_in = False
super().focusOutEvent(event)
def paintEvent(self, event):
if self._elide_mode is not None and not self._focus_in:
# This code is taken for the most part from the
# AmountEdit.paintEvent method, part of the Electrum project. See
# the Electrum entry in our NOTICE.txt file for the details.
# Licensed under the MIT license.
painter = QPainter(self)
option = QStyleOptionFrame()
self.initStyleOption(option)
text_rect = self.style().subElementRect(
QStyle.SE_LineEditContents, option, self
)
# Neded so the text is placed correctly according to our style
text_rect.adjust(2, 0, 0, 0)
fm = QFontMetrics(self.font())
text = fm.elidedText(
self.text(), self._elide_mode, text_rect.width()
)
color = (
SpyderPalette.COLOR_TEXT_1
if self.isEnabled()
else SpyderPalette.COLOR_DISABLED
)
painter.setPen(QColor(color))
painter.drawText(
text_rect, int(Qt.AlignLeft | Qt.AlignVCenter), text
)
return
super().paintEvent(event)
class _SpyderComboBoxView(QListView):
"""Listview used for comboboxes"""
sig_current_item_changed = Signal(object)
def currentChanged(self, current, previous):
# This covers selecting a different item with the keyboard or when
# hovering with the mouse over the list.
self.sig_current_item_changed.emit(current)
super().currentChanged(current, previous)
class _SpyderComboBoxMixin:
"""Mixin with the basic style and functionality for our comboboxes."""
sig_item_in_popup_changed = Signal(str)
"""
This signal is emitted when an item in the combobox popup (i.e. dropdown)
has changed.
Parameters
----------
item: str
Item text
"""
sig_popup_is_hidden = Signal()
"""
This signal is emitted when the combobox popup (i.e. dropdown) is hidden.
"""
def __init__(self):
# Style
self._css = self._generate_stylesheet()
self.setStyleSheet(self._css.toString())
style = _SpyderComboBoxProxyStyle(None)
style.setParent(self)
self.setStyle(style)
# Report when the current item in the dropdown has changed
view = _SpyderComboBoxView(self)
view.sig_current_item_changed.connect(self._on_item_changed)
self.setView(view)
def contextMenuEvent(self, event):
# Prevent showing context menu for editable comboboxes because it's
# added automatically by Qt. That means the menu is not built using our
# API and it's not localized.
pass
@qdebounced(timeout=100)
def _on_item_changed(self, index):
if index.isValid():
self.sig_item_in_popup_changed.emit(index.data())
def _generate_stylesheet(self):
"""Base stylesheet for Spyder comboboxes."""
css = qstylizer.style.StyleSheet()
# Make our comboboxes have a uniform height
css.QComboBox.setValues(
minHeight=f'{AppStyle.ComboBoxMinHeight}em'
)
# Add top and bottom padding to the inner contents of comboboxes
css["QComboBox QAbstractItemView"].setValues(
paddingTop=f"{AppStyle.MarginSize + 1}px",
paddingBottom=f"{AppStyle.MarginSize + 1}px"
)
# Add margin and padding to combobox items
css["QComboBox QAbstractItemView::item"].setValues(
marginLeft=f"{AppStyle.MarginSize}px",
marginRight=f"{AppStyle.MarginSize}px",
padding=f"{AppStyle.MarginSize}px"
)
# Make color of hovered combobox items match the one used in other
# Spyder widgets
css["QComboBox QAbstractItemView::item:selected:active"].setValues(
backgroundColor=SpyderPalette.COLOR_BACKGROUND_3,
)
return css
[docs]
class SpyderComboBox(_SpyderComboBoxMixin, QComboBox):
"""Default combobox widget for Spyder."""
def __init__(self, parent=None, items_elide_mode=None):
"""
Default combobox widget for Spyder.
Parameters
----------
parent: QWidget, optional
The combobox parent.
items_elide_mode: Qt.TextElideMode, optional
Elide mode for the combobox items.
"""
QComboBox.__init__(self, parent)
_SpyderComboBoxMixin.__init__(self)
self.is_editable = None
self._is_shown = False
self._is_popup_shown = False
# This is necessary to have more fine-grained control over the style of
# our comboboxes with css, e.g. to add more padding between its items.
# See https://stackoverflow.com/a/33464045/438386 for the details.
self.setItemDelegate(
_SpyderComboBoxDelegate(self, elide_mode=items_elide_mode)
)
[docs]
def showEvent(self, event):
"""Adjustments when the widget is shown."""
if not self._is_shown:
if not self.isEditable():
self.is_editable = False
self.setLineEdit(_SpyderComboBoxLineEdit(self, editable=False))
# This is necessary to make Qt position the popup widget below
# the combobox for non-editable ones.
# Solution from https://stackoverflow.com/a/45191141/438386
self.setEditable(True)
self.lineEdit().setReadOnly(True)
# Show popup when the lineEdit is clicked, which is the default
# behavior for non-editable comboboxes in Qt.
self.lineEdit().sig_mouse_clicked.connect(self.showPopup)
else:
self.is_editable = True
self._is_shown = True
super().showEvent(event)
[docs]
class SpyderComboBoxWithIcons(SpyderComboBox):
""""Combobox widget for Spyder when its items have icons."""
def __init__(self, parent=None, items_elide_mode=None):
""""
Combobox widget for Spyder when its items have icons.
Parameters
----------
parent: QWidget, optional
The combobox parent.
items_elide_mode: Qt.TextElideMode, optional
Elide mode for the combobox items.
"""
super().__init__(parent, items_elide_mode)
# Padding is not necessary because icons give items enough of it.
self._css["QComboBox QAbstractItemView::item"].setValues(
padding="0px"
)
self.setStyleSheet(self._css.toString())
[docs]
class SpyderFontComboBox(_SpyderComboBoxMixin, QFontComboBox):
def __init__(self, parent=None):
QFontComboBox.__init__(self, parent)
_SpyderComboBoxMixin.__init__(self)
# Avoid font name eliding because it confuses users.
# Fixes spyder-ide/spyder#22683
self.setItemDelegate(
_SpyderComboBoxDelegate(self, elide_mode=Qt.ElideNone)
)
# Elide selected font name in case it's too long
self.setLineEdit(
_SpyderComboBoxLineEdit(
self, editable=True, elide_mode=Qt.ElideMiddle
)
)
# Adjust popup width to contents.
self.setSizeAdjustPolicy(
QComboBox.AdjustToMinimumContentsLengthWithIcon
)