├── .coveragerc ├── .gitignore ├── LICENSE ├── README.md ├── demo ├── pyqt5 │ ├── main.py │ └── window.py ├── pyqt6 │ ├── main.py │ └── window.py └── pyside6 │ ├── main.py │ └── window.py ├── pytest.ini ├── requirements.txt ├── setup.py ├── src └── pyqttooltip │ ├── __init__.py │ ├── constants.py │ ├── drop_shadow.py │ ├── enums.py │ ├── placement_utils.py │ ├── tooltip.py │ ├── tooltip_interface.py │ ├── tooltip_triangle.py │ └── utils.py └── tests ├── __init__.py ├── placement_utils_test.py ├── tooltip_test.py └── utils_test.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | tests/* 4 | src/pyqttooltip/tooltip_interface.py -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Niklas Henning 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyQt Tooltip 2 | 3 | [![PyPI](https://img.shields.io/badge/pypi-v1.0.0-blue)](https://pypi.org/project/pyqttooltip/) 4 | [![Python](https://img.shields.io/badge/python-3.7+-blue)](https://github.com/niklashenning/pyqttooltip) 5 | [![Build](https://img.shields.io/badge/build-passing-neon)](https://github.com/niklashenning/pyqttooltip) 6 | [![Coverage](https://img.shields.io/badge/coverage-92%25-green)](https://github.com/niklashenning/pyqttooltip) 7 | [![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/niklashenning/pyqttooltip/blob/master/LICENSE) 8 | 9 | 10 | A modern and fully customizable tooltip library for PyQt and PySide 11 | 12 | ![pyqttooltip](https://github.com/user-attachments/assets/0313ffc7-560b-4665-a652-e1e2601fcbaa) 13 | 14 | ## Features 15 | - Fixed and automatic placement 16 | - Supports fallback placements 17 | - Customizable triangle 18 | - Customizable animations and delays 19 | - Fully customizable and modern UI 20 | - Works with `PyQt5`, `PyQt6`, `PySide2`, and `PySide6` 21 | 22 | ## Installation 23 | ``` 24 | pip install pyqttooltip 25 | ``` 26 | 27 | ## Usage 28 | ```python 29 | from PyQt6.QtWidgets import QMainWindow, QPushButton 30 | from pyqttooltip import Tooltip, TooltipPlacement 31 | 32 | 33 | class Window(QMainWindow): 34 | def __init__(self): 35 | super().__init__(parent=None) 36 | 37 | # Add button 38 | self.button = QPushButton('Button', self) 39 | 40 | # Add tooltip to button 41 | self.tooltip = Tooltip(self.button, 'This is a tooltip') 42 | ``` 43 | 44 | 45 | The tooltip will automatically be shown while hovering the widget. If you want to manually 46 | show and hide the tooltip, you can use the `show()` and `hide()` methods: 47 | ```python 48 | tooltip.show() 49 | tooltip.hide() 50 | ``` 51 | 52 | 53 | To delete a tooltip, you can use the `deleteLater()` method: 54 | ```python 55 | tooltip.deleteLater() 56 | ``` 57 | 58 | 59 | To get notified when a tooltip gets shown or hidden, you can subscribe to the `shown` and `hidden` signals: 60 | ```python 61 | tooltip.shown.connect(lambda: print('shown')) 62 | tooltip.hidden.connect(lambda: print('hidden')) 63 | ``` 64 | 65 | 66 | ## Customization 67 | 68 | * **Setting the widget:** 69 | ```python 70 | tooltip.setWidget(widget) # Default: None 71 | ``` 72 | 73 | 74 | * **Setting the text:** 75 | ```python 76 | tooltip.setText('Text of the tooltip') # Default: '' 77 | ``` 78 | 79 | 80 | * **Setting the placement:** 81 | ```python 82 | tooltip.setPlacement(TooltipPlacement.RIGHT) # Default: TooltipPlacement.AUTO 83 | ``` 84 | > **AVAILABLE PLACEMENTS:**
`AUTO`, `LEFT`, `RIGHT`, `TOP`, `BOTTOM` 85 | 86 | 87 | * **Setting the fallback placements:** 88 | ```python 89 | tooltip.setFallbackPlacements([TooltipPlacement.TOP, TooltipPlacement.BOTTOM]) # Default: [] 90 | ``` 91 | > If the tooltip doesn't fit on the screen with the primary placement, one of the 92 | > fallback placements will be chosen instead in the order of the provided list. 93 | >
To get the current placement of the tooltip, you can use the `getActualPlacement()` method. 94 | 95 | 96 | * **Enabling or disabling the triangle:** 97 | ```python 98 | tooltip.setTriangleEnabled(False) # Default: True 99 | ``` 100 | 101 | 102 | * **Setting the size of the triangle:** 103 | ```python 104 | tooltip.setTriangleSize(7) # Default: 5 105 | ``` 106 | 107 | 108 | * **Setting a duration:** 109 | ```python 110 | tooltip.setDuration(1000) # Default: 0 111 | ``` 112 | > The duration is the time in milliseconds after which the tooltip will start fading out again. 113 | > If the duration is set to `0`, the tooltip will stay visible for as long as the widget is hovered. 114 | 115 | 116 | * **Setting the offsets:** 117 | ```python 118 | # Setting the offset for a specific placement 119 | tooltip.setOffsetByPlacement(TooltipPlacement.LEFT, QPoint(-10, 5)) 120 | 121 | # Using a dict that specifies the offset for each placement you want to set 122 | offsets = { 123 | TooltipPlacement.LEFT: QPoint(-10, 5), 124 | TooltipPlacement.RIGHT: QPoint(10, 5), 125 | TooltipPlacement.TOP: QPoint(5, -10), 126 | TooltipPlacement.BOTTOM: QPoint(5, 10) 127 | } 128 | tooltip.setOffsets(offsets) 129 | 130 | # Setting the offsets for all the placements to a single value 131 | tooltip.setOffsetsAll(QPoint(10, 5)) 132 | ``` 133 | > Each placement / side has its own offset to allow for full customizability. 134 | > Each offset is a QPoint, which is made up of an x and y value. 135 | >
By default, all the offsets are set to `QPoint(0, 0)`. 136 | 137 | 138 | * **Adding delays to the fade in / out animations after hovering the widget:** 139 | ```python 140 | tooltip.setShowDelay(500) # Default: 50 141 | tooltip.setHideDelay(500) # Default: 50 142 | ``` 143 | 144 | 145 | * **Setting the durations of the fade in / out animations:** 146 | ```python 147 | tooltip.setFadeInDuration(250) # Default: 150 148 | tooltip.setFadeOutDuration(250) # Default: 150 149 | ``` 150 | 151 | 152 | * **Setting the border radius:** 153 | ```python 154 | tooltip.setBorderRadius(0) # Default: 2 155 | ``` 156 | 157 | 158 | * **Enabling or disabling the border:** 159 | ```python 160 | tooltip.setBorderEnabled(True) # Default: False 161 | ``` 162 | 163 | 164 | * **Setting custom colors:** 165 | ```python 166 | tooltip.setBackgroundColor(QColor('#FCBA03')) # Default: QColor('#111214') 167 | tooltip.setTextColor(QColor('#000000')) # Default: QColor('#CFD2D5') 168 | tooltip.setBorderColor(QColor('#A38329')) # Default: QColor('#403E41') 169 | ``` 170 | 171 | 172 | * **Setting a custom font:** 173 | ```python 174 | tooltip.setFont(QFont('Consolas', 10)) # Default: QFont('Arial', 9, QFont.Weight.Bold) 175 | ``` 176 | 177 | 178 | * **Applying margins to the content of the tooltip:** 179 | ```python 180 | tooltip.setMargins(QMargins(10, 8, 10, 8)) # Default: QMargins(12, 8, 12, 7) 181 | ``` 182 | 183 | 184 | * **Setting a maximum width:** 185 | ```python 186 | tooltip.setMaximumWidth(150) # Default: 16777215 (QWIDGETSIZE_MAX) 187 | ``` 188 | 189 | 190 | * **Enabling or disabling text centering for wrapped text:** 191 | ```python 192 | tooltip.setTextCenteringEnabled(False) # Default: True 193 | ``` 194 | 195 | 196 | * **Enabling or disabling the drop shadow:** 197 | ```python 198 | tooltip.setDropShadowEnabled(False) # Default: True 199 | ``` 200 | 201 | 202 | * **Changing the drop shadow strength:** 203 | ```python 204 | tooltip.setDropShadowStrength(3.5) # Default: 2.0 205 | ``` 206 | 207 | 208 | * **Making the tooltip translucent:** 209 | ```python 210 | tooltip.setOpacity(0.8) # Default: 1.0 211 | ``` 212 | 213 | 214 | **
Other customization options:** 215 | 216 | | Option | Description | Default | 217 | |-----------------------------|---------------------------------------------------------------|----------------------------| 218 | | `setShowingOnDisabled()` | Whether the tooltips should also be shown on disabled widgets | `False` | 219 | | `setFadeInEasingCurve()` | The easing curve of the fade in animation | `QEasingCurve.Type.Linear` | 220 | | `setFadeOutEasingCurve()` | The easing curve of the fade out animation | `QEasingCurve.Type.Linear` | 221 | | `setMarginLeft()` | Set left margin individually | `12` | 222 | | `setMarginRight()` | Set right margin individually | `12` | 223 | | `setMarginTop()` | Set top margin individually | `8` | 224 | | `setMarginBottom()` | Set bottom margin individually | `7` | 225 | 226 | 227 | ## Demo 228 | 229 | https://github.com/user-attachments/assets/fa768d30-f3cc-4883-aa8b-fed3a8824b23 230 | 231 | The demos for PyQt5, PyQt6, and PySide6 can be found in the [demo](https://github.com/niklashenning/pyqttooltip/blob/master/demo) folder. 232 | 233 | > To keep the demo simple, only the most important features are included. 234 | > To get an overview of all the customization options, check out the documentation above. 235 | 236 | 237 | ## Tests 238 | Installing the required test dependencies [PyQt6](https://pypi.org/project/PyQt6/), [pytest](https://github.com/pytest-dev/pytest), and [coveragepy](https://github.com/nedbat/coveragepy): 239 | ``` 240 | pip install PyQt6 pytest coverage 241 | ``` 242 | 243 | To run the tests with coverage, clone this repository, go into the main directory and run: 244 | ``` 245 | coverage run -m pytest 246 | coverage report --ignore-errors -m 247 | ``` 248 | 249 | ## License 250 | This software is licensed under the [MIT license](https://github.com/niklashenning/pyqttooltip/blob/master/LICENSE). 251 | -------------------------------------------------------------------------------- /demo/pyqt5/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PyQt5.QtWidgets import QApplication 3 | from window import Window 4 | 5 | 6 | # Run demo 7 | if __name__ == '__main__': 8 | app = QApplication(sys.argv) 9 | window = Window() 10 | window.show() 11 | sys.exit(app.exec_()) 12 | -------------------------------------------------------------------------------- /demo/pyqt5/window.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import ( 2 | QMainWindow, QWidget, QPushButton, QVBoxLayout, QHBoxLayout, QFormLayout, 3 | QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox 4 | ) 5 | from PyQt5.QtCore import QPoint, Qt 6 | from PyQt5.QtGui import QColor 7 | from pyqttooltip import Tooltip, TooltipPlacement 8 | 9 | 10 | class Window(QMainWindow): 11 | 12 | def __init__(self): 13 | super().__init__(parent=None) 14 | 15 | # Window settings 16 | self.setWindowTitle('PyQt Tooltip Demo') 17 | self.setFixedSize(600, 320) 18 | 19 | # Create tooltip widget and tooltip 20 | self.tooltip_widget = QPushButton('Show tooltip', self) 21 | self.tooltip_widget.setFixedSize(110, 30) 22 | 23 | self.tooltip = Tooltip(self.tooltip_widget, 'This is a tooltip') 24 | 25 | # Create settings layout 26 | settings_layout = QHBoxLayout() 27 | settings_layout.addLayout(self.create_left_settings_layout()) 28 | settings_layout.addLayout(self.create_right_settings_layout()) 29 | settings_layout.setContentsMargins(0, 35, 0, 0) 30 | 31 | # Create main layout 32 | main_layout = QVBoxLayout() 33 | main_layout.addWidget(self.tooltip_widget, alignment=Qt.AlignmentFlag.AlignHCenter) 34 | main_layout.addLayout(settings_layout) 35 | main_layout.setContentsMargins(25, 40, 25, 25) 36 | 37 | central_widget = QWidget() 38 | central_widget.setLayout(main_layout) 39 | self.setCentralWidget(central_widget) 40 | 41 | def create_left_settings_layout(self): 42 | # Create settings widgets 43 | self.placement_dropdown = QComboBox() 44 | self.placement_dropdown.addItems(['AUTO', 'LEFT', 'RIGHT', 'TOP', 'BOTTOM']) 45 | self.placement_dropdown.currentTextChanged.connect(self.placement_dropdown_changed) 46 | 47 | self.text_input = QLineEdit() 48 | self.text_input.setText(self.tooltip.getText()) 49 | self.text_input.textChanged.connect(self.text_input_changed) 50 | 51 | self.max_width_input = QSpinBox() 52 | self.max_width_input.setRange(50, 1000) 53 | self.max_width_input.setValue(self.tooltip.maximumWidth()) 54 | self.max_width_input.valueChanged.connect(self.max_width_input_changed) 55 | 56 | self.opacity_input = QDoubleSpinBox() 57 | self.opacity_input.setRange(0.0, 1.0) 58 | self.opacity_input.setSingleStep(0.05) 59 | self.opacity_input.setValue(self.tooltip.getOpacity()) 60 | self.opacity_input.valueChanged.connect(self.opacity_input_changed) 61 | 62 | self.fade_duration_input = QSpinBox() 63 | self.fade_duration_input.setRange(0, 5000) 64 | self.fade_duration_input.setValue(self.tooltip.getFadeInDuration()) 65 | self.fade_duration_input.valueChanged.connect(self.fade_duration_input_changed) 66 | 67 | self.delay_input = QSpinBox() 68 | self.delay_input.setRange(0, 2500) 69 | self.delay_input.setValue(self.tooltip.getShowDelay()) 70 | self.delay_input.valueChanged.connect(self.delay_input_changed) 71 | 72 | self.triangle_size_input = QSpinBox() 73 | self.triangle_size_input.setRange(0, 25) 74 | self.triangle_size_input.setValue(self.tooltip.getTriangleSize()) 75 | self.triangle_size_input.valueChanged.connect(self.triangle_size_input_changed) 76 | 77 | # Add widgets to layout 78 | left_settings_layout = QFormLayout() 79 | left_settings_layout.addRow('Placement: ', self.placement_dropdown) 80 | left_settings_layout.addRow('Text: ', self.text_input) 81 | left_settings_layout.addRow('Max width: ', self.max_width_input) 82 | left_settings_layout.addRow('Opacity: ', self.opacity_input) 83 | left_settings_layout.addRow('Fade duration: ', self.fade_duration_input) 84 | left_settings_layout.addRow('Delay: ', self.delay_input) 85 | left_settings_layout.addRow('Triangle size: ', self.triangle_size_input) 86 | left_settings_layout.setContentsMargins(0, 0, 10, 0) 87 | 88 | return left_settings_layout 89 | 90 | def create_right_settings_layout(self): 91 | # Create settings widgets 92 | self.border_radius_input = QSpinBox() 93 | self.border_radius_input.setRange(0, 10) 94 | self.border_radius_input.setValue(self.tooltip.getBorderRadius()) 95 | self.border_radius_input.valueChanged.connect(self.border_radius_input_changed) 96 | 97 | self.offset_x_input = QSpinBox() 98 | self.offset_x_input.setRange(-500, 500) 99 | self.offset_x_input.valueChanged.connect(self.offset_x_input_changed) 100 | 101 | self.offset_y_input = QSpinBox() 102 | self.offset_y_input.setRange(-500, 500) 103 | self.offset_y_input.valueChanged.connect(self.offset_y_input_changed) 104 | 105 | self.background_color_input = QLineEdit() 106 | self.background_color_input.setText(self.tooltip.getBackgroundColor().name()) 107 | self.background_color_input.textChanged.connect(self.background_color_input_changed) 108 | 109 | self.text_color_input = QLineEdit() 110 | self.text_color_input.setText(self.tooltip.getTextColor().name()) 111 | self.text_color_input.textChanged.connect(self.text_color_input_changed) 112 | 113 | self.border_color_input = QLineEdit() 114 | self.border_color_input.setText(self.tooltip.getBorderColor().name()) 115 | self.border_color_input.textChanged.connect(self.border_color_input_changed) 116 | 117 | self.border_enabled_input = QCheckBox('Border enabled') 118 | self.border_enabled_input.stateChanged.connect(self.border_enabled_input_changed) 119 | 120 | # Add widgets to layout 121 | right_settings_layout = QFormLayout() 122 | right_settings_layout.addRow('Border radius: ', self.border_radius_input) 123 | right_settings_layout.addRow('Offset X: ', self.offset_x_input) 124 | right_settings_layout.addRow('Offset Y: ', self.offset_y_input) 125 | right_settings_layout.addRow('Background color: ', self.background_color_input) 126 | right_settings_layout.addRow('Text color: ', self.text_color_input) 127 | right_settings_layout.addRow('Border color: ', self.border_color_input) 128 | right_settings_layout.addWidget(self.border_enabled_input) 129 | right_settings_layout.setContentsMargins(10, 0, 0, 0) 130 | 131 | return right_settings_layout 132 | 133 | def placement_dropdown_changed(self, item): 134 | if item == 'AUTO': 135 | self.tooltip.setPlacement(TooltipPlacement.AUTO) 136 | elif item == 'LEFT': 137 | self.tooltip.setPlacement(TooltipPlacement.LEFT) 138 | elif item == 'RIGHT': 139 | self.tooltip.setPlacement(TooltipPlacement.RIGHT) 140 | elif item == 'TOP': 141 | self.tooltip.setPlacement(TooltipPlacement.TOP) 142 | elif item == 'BOTTOM': 143 | self.tooltip.setPlacement(TooltipPlacement.BOTTOM) 144 | 145 | def text_input_changed(self, text): 146 | self.tooltip.setText(text) 147 | 148 | def max_width_input_changed(self, max_width): 149 | self.tooltip.setMaximumWidth(max_width) 150 | 151 | def opacity_input_changed(self, opacity): 152 | self.tooltip.setOpacity(opacity) 153 | 154 | def fade_duration_input_changed(self, fade_duration): 155 | self.tooltip.setFadeInDuration(fade_duration) 156 | self.tooltip.setFadeOutDuration(fade_duration) 157 | 158 | def delay_input_changed(self, delay): 159 | self.tooltip.setShowDelay(delay) 160 | self.tooltip.setHideDelay(delay) 161 | 162 | def triangle_size_input_changed(self, triangle_size): 163 | self.tooltip.setTriangleSize(triangle_size) 164 | 165 | def border_radius_input_changed(self, border_radius): 166 | self.tooltip.setBorderRadius(border_radius) 167 | 168 | def offset_x_input_changed(self, offset_x): 169 | self.tooltip.setOffsetsAll( 170 | QPoint(offset_x, self.tooltip.getOffsetByPlacement(self.tooltip.getActualPlacement()).y()) 171 | ) 172 | 173 | def offset_y_input_changed(self, offset_y): 174 | self.tooltip.setOffsetsAll( 175 | QPoint(self.tooltip.getOffsetByPlacement(self.tooltip.getActualPlacement()).x(), offset_y) 176 | ) 177 | 178 | def background_color_input_changed(self, text): 179 | self.tooltip.setBackgroundColor(QColor(text)) 180 | 181 | def text_color_input_changed(self, text): 182 | self.tooltip.setTextColor(QColor(text)) 183 | 184 | def border_color_input_changed(self, text): 185 | self.tooltip.setBorderColor(QColor(text)) 186 | 187 | def border_enabled_input_changed(self, state): 188 | self.tooltip.setBorderEnabled(state) 189 | -------------------------------------------------------------------------------- /demo/pyqt6/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PyQt6.QtWidgets import QApplication 3 | from window import Window 4 | 5 | 6 | # Run demo 7 | if __name__ == '__main__': 8 | app = QApplication(sys.argv) 9 | window = Window() 10 | window.show() 11 | app.exec() 12 | -------------------------------------------------------------------------------- /demo/pyqt6/window.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import ( 2 | QMainWindow, QWidget, QPushButton, QVBoxLayout, QHBoxLayout, QFormLayout, 3 | QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox 4 | ) 5 | from PyQt6.QtCore import QPoint, Qt 6 | from PyQt6.QtGui import QColor 7 | from pyqttooltip import Tooltip, TooltipPlacement 8 | 9 | 10 | class Window(QMainWindow): 11 | 12 | def __init__(self): 13 | super().__init__(parent=None) 14 | 15 | # Window settings 16 | self.setWindowTitle('PyQt Tooltip Demo') 17 | self.setFixedSize(600, 330) 18 | 19 | # Create tooltip widget and tooltip 20 | self.tooltip_widget = QPushButton('Show tooltip', self) 21 | self.tooltip_widget.setFixedSize(110, 30) 22 | 23 | self.tooltip = Tooltip(self.tooltip_widget, 'This is a tooltip') 24 | 25 | # Create settings layout 26 | settings_layout = QHBoxLayout() 27 | settings_layout.addLayout(self.create_left_settings_layout()) 28 | settings_layout.addLayout(self.create_right_settings_layout()) 29 | settings_layout.setContentsMargins(0, 35, 0, 0) 30 | 31 | # Create main layout 32 | main_layout = QVBoxLayout() 33 | main_layout.addWidget(self.tooltip_widget, alignment=Qt.AlignmentFlag.AlignHCenter) 34 | main_layout.addLayout(settings_layout) 35 | main_layout.setContentsMargins(25, 40, 25, 25) 36 | 37 | central_widget = QWidget() 38 | central_widget.setLayout(main_layout) 39 | self.setCentralWidget(central_widget) 40 | 41 | def create_left_settings_layout(self): 42 | # Create settings widgets 43 | self.placement_dropdown = QComboBox() 44 | self.placement_dropdown.addItems(['AUTO', 'LEFT', 'RIGHT', 'TOP', 'BOTTOM']) 45 | self.placement_dropdown.currentTextChanged.connect(self.placement_dropdown_changed) 46 | 47 | self.text_input = QLineEdit() 48 | self.text_input.setText(self.tooltip.getText()) 49 | self.text_input.textChanged.connect(self.text_input_changed) 50 | 51 | self.max_width_input = QSpinBox() 52 | self.max_width_input.setRange(50, 1000) 53 | self.max_width_input.setValue(self.tooltip.maximumWidth()) 54 | self.max_width_input.valueChanged.connect(self.max_width_input_changed) 55 | 56 | self.opacity_input = QDoubleSpinBox() 57 | self.opacity_input.setRange(0.0, 1.0) 58 | self.opacity_input.setSingleStep(0.05) 59 | self.opacity_input.setValue(self.tooltip.getOpacity()) 60 | self.opacity_input.valueChanged.connect(self.opacity_input_changed) 61 | 62 | self.fade_duration_input = QSpinBox() 63 | self.fade_duration_input.setRange(0, 5000) 64 | self.fade_duration_input.setValue(self.tooltip.getFadeInDuration()) 65 | self.fade_duration_input.valueChanged.connect(self.fade_duration_input_changed) 66 | 67 | self.delay_input = QSpinBox() 68 | self.delay_input.setRange(0, 2500) 69 | self.delay_input.setValue(self.tooltip.getShowDelay()) 70 | self.delay_input.valueChanged.connect(self.delay_input_changed) 71 | 72 | self.triangle_size_input = QSpinBox() 73 | self.triangle_size_input.setRange(0, 25) 74 | self.triangle_size_input.setValue(self.tooltip.getTriangleSize()) 75 | self.triangle_size_input.valueChanged.connect(self.triangle_size_input_changed) 76 | 77 | # Add widgets to layout 78 | left_settings_layout = QFormLayout() 79 | left_settings_layout.addRow('Placement: ', self.placement_dropdown) 80 | left_settings_layout.addRow('Text: ', self.text_input) 81 | left_settings_layout.addRow('Max width: ', self.max_width_input) 82 | left_settings_layout.addRow('Opacity: ', self.opacity_input) 83 | left_settings_layout.addRow('Fade duration: ', self.fade_duration_input) 84 | left_settings_layout.addRow('Delay: ', self.delay_input) 85 | left_settings_layout.addRow('Triangle size: ', self.triangle_size_input) 86 | left_settings_layout.setContentsMargins(0, 0, 10, 0) 87 | 88 | return left_settings_layout 89 | 90 | def create_right_settings_layout(self): 91 | # Create settings widgets 92 | self.border_radius_input = QSpinBox() 93 | self.border_radius_input.setRange(0, 10) 94 | self.border_radius_input.setValue(self.tooltip.getBorderRadius()) 95 | self.border_radius_input.valueChanged.connect(self.border_radius_input_changed) 96 | 97 | self.offset_x_input = QSpinBox() 98 | self.offset_x_input.setRange(-500, 500) 99 | self.offset_x_input.valueChanged.connect(self.offset_x_input_changed) 100 | 101 | self.offset_y_input = QSpinBox() 102 | self.offset_y_input.setRange(-500, 500) 103 | self.offset_y_input.valueChanged.connect(self.offset_y_input_changed) 104 | 105 | self.background_color_input = QLineEdit() 106 | self.background_color_input.setText(self.tooltip.getBackgroundColor().name()) 107 | self.background_color_input.textChanged.connect(self.background_color_input_changed) 108 | 109 | self.text_color_input = QLineEdit() 110 | self.text_color_input.setText(self.tooltip.getTextColor().name()) 111 | self.text_color_input.textChanged.connect(self.text_color_input_changed) 112 | 113 | self.border_color_input = QLineEdit() 114 | self.border_color_input.setText(self.tooltip.getBorderColor().name()) 115 | self.border_color_input.textChanged.connect(self.border_color_input_changed) 116 | 117 | self.border_enabled_input = QCheckBox('Border enabled') 118 | self.border_enabled_input.stateChanged.connect(self.border_enabled_input_changed) 119 | 120 | # Add widgets to layout 121 | right_settings_layout = QFormLayout() 122 | right_settings_layout.addRow('Border radius: ', self.border_radius_input) 123 | right_settings_layout.addRow('Offset X: ', self.offset_x_input) 124 | right_settings_layout.addRow('Offset Y: ', self.offset_y_input) 125 | right_settings_layout.addRow('Background color: ', self.background_color_input) 126 | right_settings_layout.addRow('Text color: ', self.text_color_input) 127 | right_settings_layout.addRow('Border color: ', self.border_color_input) 128 | right_settings_layout.addWidget(self.border_enabled_input) 129 | right_settings_layout.setContentsMargins(10, 0, 0, 0) 130 | 131 | return right_settings_layout 132 | 133 | def placement_dropdown_changed(self, item): 134 | if item == 'AUTO': 135 | self.tooltip.setPlacement(TooltipPlacement.AUTO) 136 | elif item == 'LEFT': 137 | self.tooltip.setPlacement(TooltipPlacement.LEFT) 138 | elif item == 'RIGHT': 139 | self.tooltip.setPlacement(TooltipPlacement.RIGHT) 140 | elif item == 'TOP': 141 | self.tooltip.setPlacement(TooltipPlacement.TOP) 142 | elif item == 'BOTTOM': 143 | self.tooltip.setPlacement(TooltipPlacement.BOTTOM) 144 | 145 | def text_input_changed(self, text): 146 | self.tooltip.setText(text) 147 | 148 | def max_width_input_changed(self, max_width): 149 | self.tooltip.setMaximumWidth(max_width) 150 | 151 | def opacity_input_changed(self, opacity): 152 | self.tooltip.setOpacity(opacity) 153 | 154 | def fade_duration_input_changed(self, fade_duration): 155 | self.tooltip.setFadeInDuration(fade_duration) 156 | self.tooltip.setFadeOutDuration(fade_duration) 157 | 158 | def delay_input_changed(self, delay): 159 | self.tooltip.setShowDelay(delay) 160 | self.tooltip.setHideDelay(delay) 161 | 162 | def triangle_size_input_changed(self, triangle_size): 163 | self.tooltip.setTriangleSize(triangle_size) 164 | 165 | def border_radius_input_changed(self, border_radius): 166 | self.tooltip.setBorderRadius(border_radius) 167 | 168 | def offset_x_input_changed(self, offset_x): 169 | self.tooltip.setOffsetsAll( 170 | QPoint(offset_x, self.tooltip.getOffsetByPlacement(self.tooltip.getActualPlacement()).y()) 171 | ) 172 | 173 | def offset_y_input_changed(self, offset_y): 174 | self.tooltip.setOffsetsAll( 175 | QPoint(self.tooltip.getOffsetByPlacement(self.tooltip.getActualPlacement()).x(), offset_y) 176 | ) 177 | 178 | def background_color_input_changed(self, text): 179 | self.tooltip.setBackgroundColor(QColor(text)) 180 | 181 | def text_color_input_changed(self, text): 182 | self.tooltip.setTextColor(QColor(text)) 183 | 184 | def border_color_input_changed(self, text): 185 | self.tooltip.setBorderColor(QColor(text)) 186 | 187 | def border_enabled_input_changed(self, state): 188 | self.tooltip.setBorderEnabled(state) 189 | -------------------------------------------------------------------------------- /demo/pyside6/main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PySide6.QtWidgets import QApplication 3 | from window import Window 4 | 5 | 6 | # Run demo 7 | if __name__ == '__main__': 8 | app = QApplication(sys.argv) 9 | window = Window() 10 | window.show() 11 | sys.exit(app.exec_()) 12 | -------------------------------------------------------------------------------- /demo/pyside6/window.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import ( 2 | QMainWindow, QWidget, QPushButton, QVBoxLayout, QHBoxLayout, QFormLayout, 3 | QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox 4 | ) 5 | from PySide6.QtCore import QPoint, Qt 6 | from PySide6.QtGui import QColor 7 | from pyqttooltip import Tooltip, TooltipPlacement 8 | 9 | 10 | class Window(QMainWindow): 11 | 12 | def __init__(self): 13 | super().__init__(parent=None) 14 | 15 | # Window settings 16 | self.setWindowTitle('PyQt Tooltip Demo') 17 | self.setFixedSize(600, 330) 18 | 19 | # Create tooltip widget and tooltip 20 | self.tooltip_widget = QPushButton('Show tooltip', self) 21 | self.tooltip_widget.setFixedSize(110, 30) 22 | 23 | self.tooltip = Tooltip(self.tooltip_widget, 'This is a tooltip') 24 | 25 | # Create settings layout 26 | settings_layout = QHBoxLayout() 27 | settings_layout.addLayout(self.create_left_settings_layout()) 28 | settings_layout.addLayout(self.create_right_settings_layout()) 29 | settings_layout.setContentsMargins(0, 35, 0, 0) 30 | 31 | # Create main layout 32 | main_layout = QVBoxLayout() 33 | main_layout.addWidget(self.tooltip_widget, alignment=Qt.AlignmentFlag.AlignHCenter) 34 | main_layout.addLayout(settings_layout) 35 | main_layout.setContentsMargins(25, 40, 25, 25) 36 | 37 | central_widget = QWidget() 38 | central_widget.setLayout(main_layout) 39 | self.setCentralWidget(central_widget) 40 | 41 | def create_left_settings_layout(self): 42 | # Create settings widgets 43 | self.placement_dropdown = QComboBox() 44 | self.placement_dropdown.addItems(['AUTO', 'LEFT', 'RIGHT', 'TOP', 'BOTTOM']) 45 | self.placement_dropdown.currentTextChanged.connect(self.placement_dropdown_changed) 46 | 47 | self.text_input = QLineEdit() 48 | self.text_input.setText(self.tooltip.getText()) 49 | self.text_input.textChanged.connect(self.text_input_changed) 50 | 51 | self.max_width_input = QSpinBox() 52 | self.max_width_input.setRange(50, 1000) 53 | self.max_width_input.setValue(self.tooltip.maximumWidth()) 54 | self.max_width_input.valueChanged.connect(self.max_width_input_changed) 55 | 56 | self.opacity_input = QDoubleSpinBox() 57 | self.opacity_input.setRange(0.0, 1.0) 58 | self.opacity_input.setSingleStep(0.05) 59 | self.opacity_input.setValue(self.tooltip.getOpacity()) 60 | self.opacity_input.valueChanged.connect(self.opacity_input_changed) 61 | 62 | self.fade_duration_input = QSpinBox() 63 | self.fade_duration_input.setRange(0, 5000) 64 | self.fade_duration_input.setValue(self.tooltip.getFadeInDuration()) 65 | self.fade_duration_input.valueChanged.connect(self.fade_duration_input_changed) 66 | 67 | self.delay_input = QSpinBox() 68 | self.delay_input.setRange(0, 2500) 69 | self.delay_input.setValue(self.tooltip.getShowDelay()) 70 | self.delay_input.valueChanged.connect(self.delay_input_changed) 71 | 72 | self.triangle_size_input = QSpinBox() 73 | self.triangle_size_input.setRange(0, 25) 74 | self.triangle_size_input.setValue(self.tooltip.getTriangleSize()) 75 | self.triangle_size_input.valueChanged.connect(self.triangle_size_input_changed) 76 | 77 | # Add widgets to layout 78 | left_settings_layout = QFormLayout() 79 | left_settings_layout.addRow('Placement: ', self.placement_dropdown) 80 | left_settings_layout.addRow('Text: ', self.text_input) 81 | left_settings_layout.addRow('Max width: ', self.max_width_input) 82 | left_settings_layout.addRow('Opacity: ', self.opacity_input) 83 | left_settings_layout.addRow('Fade duration: ', self.fade_duration_input) 84 | left_settings_layout.addRow('Delay: ', self.delay_input) 85 | left_settings_layout.addRow('Triangle size: ', self.triangle_size_input) 86 | left_settings_layout.setContentsMargins(0, 0, 10, 0) 87 | 88 | return left_settings_layout 89 | 90 | def create_right_settings_layout(self): 91 | # Create settings widgets 92 | self.border_radius_input = QSpinBox() 93 | self.border_radius_input.setRange(0, 10) 94 | self.border_radius_input.setValue(self.tooltip.getBorderRadius()) 95 | self.border_radius_input.valueChanged.connect(self.border_radius_input_changed) 96 | 97 | self.offset_x_input = QSpinBox() 98 | self.offset_x_input.setRange(-500, 500) 99 | self.offset_x_input.valueChanged.connect(self.offset_x_input_changed) 100 | 101 | self.offset_y_input = QSpinBox() 102 | self.offset_y_input.setRange(-500, 500) 103 | self.offset_y_input.valueChanged.connect(self.offset_y_input_changed) 104 | 105 | self.background_color_input = QLineEdit() 106 | self.background_color_input.setText(self.tooltip.getBackgroundColor().name()) 107 | self.background_color_input.textChanged.connect(self.background_color_input_changed) 108 | 109 | self.text_color_input = QLineEdit() 110 | self.text_color_input.setText(self.tooltip.getTextColor().name()) 111 | self.text_color_input.textChanged.connect(self.text_color_input_changed) 112 | 113 | self.border_color_input = QLineEdit() 114 | self.border_color_input.setText(self.tooltip.getBorderColor().name()) 115 | self.border_color_input.textChanged.connect(self.border_color_input_changed) 116 | 117 | self.border_enabled_input = QCheckBox('Border enabled') 118 | self.border_enabled_input.stateChanged.connect(self.border_enabled_input_changed) 119 | 120 | # Add widgets to layout 121 | right_settings_layout = QFormLayout() 122 | right_settings_layout.addRow('Border radius: ', self.border_radius_input) 123 | right_settings_layout.addRow('Offset X: ', self.offset_x_input) 124 | right_settings_layout.addRow('Offset Y: ', self.offset_y_input) 125 | right_settings_layout.addRow('Background color: ', self.background_color_input) 126 | right_settings_layout.addRow('Text color: ', self.text_color_input) 127 | right_settings_layout.addRow('Border color: ', self.border_color_input) 128 | right_settings_layout.addWidget(self.border_enabled_input) 129 | right_settings_layout.setContentsMargins(10, 0, 0, 0) 130 | 131 | return right_settings_layout 132 | 133 | def placement_dropdown_changed(self, item): 134 | if item == 'AUTO': 135 | self.tooltip.setPlacement(TooltipPlacement.AUTO) 136 | elif item == 'LEFT': 137 | self.tooltip.setPlacement(TooltipPlacement.LEFT) 138 | elif item == 'RIGHT': 139 | self.tooltip.setPlacement(TooltipPlacement.RIGHT) 140 | elif item == 'TOP': 141 | self.tooltip.setPlacement(TooltipPlacement.TOP) 142 | elif item == 'BOTTOM': 143 | self.tooltip.setPlacement(TooltipPlacement.BOTTOM) 144 | 145 | def text_input_changed(self, text): 146 | self.tooltip.setText(text) 147 | 148 | def max_width_input_changed(self, max_width): 149 | self.tooltip.setMaximumWidth(max_width) 150 | 151 | def opacity_input_changed(self, opacity): 152 | self.tooltip.setOpacity(opacity) 153 | 154 | def fade_duration_input_changed(self, fade_duration): 155 | self.tooltip.setFadeInDuration(fade_duration) 156 | self.tooltip.setFadeOutDuration(fade_duration) 157 | 158 | def delay_input_changed(self, delay): 159 | self.tooltip.setShowDelay(delay) 160 | self.tooltip.setHideDelay(delay) 161 | 162 | def triangle_size_input_changed(self, triangle_size): 163 | self.tooltip.setTriangleSize(triangle_size) 164 | 165 | def border_radius_input_changed(self, border_radius): 166 | self.tooltip.setBorderRadius(border_radius) 167 | 168 | def offset_x_input_changed(self, offset_x): 169 | self.tooltip.setOffsetsAll( 170 | QPoint(offset_x, self.tooltip.getOffsetByPlacement(self.tooltip.getActualPlacement()).y()) 171 | ) 172 | 173 | def offset_y_input_changed(self, offset_y): 174 | self.tooltip.setOffsetsAll( 175 | QPoint(self.tooltip.getOffsetByPlacement(self.tooltip.getActualPlacement()).x(), offset_y) 176 | ) 177 | 178 | def background_color_input_changed(self, text): 179 | self.tooltip.setBackgroundColor(QColor(text)) 180 | 181 | def text_color_input_changed(self, text): 182 | self.tooltip.setTextColor(QColor(text)) 183 | 184 | def border_color_input_changed(self, text): 185 | self.tooltip.setBorderColor(QColor(text)) 186 | 187 | def border_enabled_input_changed(self, state): 188 | self.tooltip.setBorderEnabled(state) 189 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | qt_api=pyqt6 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | QtPy>=2.4.1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_namespace_packages 2 | 3 | 4 | with open('README.md', 'r') as fh: 5 | readme = '\n' + fh.read() 6 | 7 | setup( 8 | name='pyqttooltip', 9 | version='1.0.0', 10 | author='Niklas Henning', 11 | author_email='business@niklashenning.com', 12 | license='MIT', 13 | packages=find_namespace_packages(where='src'), 14 | package_dir={'': 'src'}, 15 | install_requires=[ 16 | 'QtPy>=2.4.1' 17 | ], 18 | python_requires='>=3.7', 19 | description='A modern and fully customizable tooltip library for PyQt and PySide', 20 | long_description=readme, 21 | long_description_content_type='text/markdown', 22 | url='https://github.com/niklashenning/pyqttooltip', 23 | keywords=['python', 'pyqt', 'qt', 'tooltip', 'modern'], 24 | classifiers=[ 25 | 'Programming Language :: Python :: 3', 26 | 'Operating System :: OS Independent', 27 | 'License :: OSI Approved :: MIT License' 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /src/pyqttooltip/__init__.py: -------------------------------------------------------------------------------- 1 | from .tooltip import Tooltip, TooltipPlacement 2 | -------------------------------------------------------------------------------- /src/pyqttooltip/constants.py: -------------------------------------------------------------------------------- 1 | 2 | QWIDGETSIZE_MAX = 16777215 3 | DROP_SHADOW_SIZE = 10 4 | -------------------------------------------------------------------------------- /src/pyqttooltip/drop_shadow.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QWidget 2 | from qtpy.QtCore import QSize 3 | from .tooltip_interface import TooltipInterface 4 | from .constants import * 5 | 6 | 7 | class DropShadow(QWidget): 8 | 9 | def __init__(self, tooltip: TooltipInterface): 10 | """Create a new DropShadow instance 11 | 12 | :param tooltip: tooltip the drop shadow belongs to 13 | """ 14 | 15 | super(DropShadow, self).__init__(tooltip) 16 | 17 | self.tooltip = tooltip 18 | 19 | # Drop shadow drawn manually since only one graphics effect can be applied 20 | self.layers = [] 21 | 22 | for i in range(DROP_SHADOW_SIZE): 23 | layer = QWidget(self) 24 | self.__apply_layer_stylesheet(layer, i) 25 | self.layers.append(layer) 26 | 27 | def update(self): 28 | """Update the stylesheets of the layers""" 29 | 30 | for i, layer in enumerate(self.layers): 31 | self.__apply_layer_stylesheet(layer, i) 32 | 33 | def resize(self, size: QSize): 34 | """Resize the drop shadow widget 35 | 36 | :param size: new size 37 | """ 38 | 39 | super().resize(size) 40 | width = size.width() 41 | height = size.height() 42 | 43 | # Resize and move drop shadow layers 44 | for i, layer in enumerate(self.layers): 45 | layer.resize(width - i * 2, height - i * 2) 46 | layer.move(i, i) 47 | 48 | def __apply_layer_stylesheet(self, layer: QWidget, index: int): 49 | """Apply stylesheet to a layer widget 50 | 51 | :param layer: layer to apply the stylesheet to 52 | :param index: index of the layer 53 | """ 54 | 55 | layer.setStyleSheet( 56 | 'background: rgba(0, 0, 0, {}); ' 57 | 'border-radius: 8px;' 58 | .format((index + 1) * 0.001 * self.tooltip.getDropShadowStrength()) 59 | ) 60 | -------------------------------------------------------------------------------- /src/pyqttooltip/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class TooltipPlacement(Enum): 5 | AUTO = 0 6 | LEFT = 1 7 | RIGHT = 2 8 | TOP = 3 9 | BOTTOM = 4 10 | -------------------------------------------------------------------------------- /src/pyqttooltip/placement_utils.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QWidget, QApplication 2 | from qtpy.QtCore import QRect, QSize, QPoint 3 | from .enums import TooltipPlacement 4 | from .utils import Utils 5 | 6 | 7 | class PlacementUtils: 8 | 9 | @staticmethod 10 | def get_optimal_placement(widget: QWidget, size: QSize, triangle_size: int, 11 | offsets: dict[TooltipPlacement, QPoint]) -> TooltipPlacement: 12 | """Calculate the optimal placement of a tooltip based on the widget, 13 | size, triangle size, and offsets. 14 | 15 | :param widget: widget of the tooltip 16 | :param size: size of the tooltip 17 | :param triangle_size: size of the triangle 18 | :param offsets: offsets of the tooltip 19 | :return: optimal placement 20 | """ 21 | 22 | top_level_parent = Utils.get_top_level_parent(widget) 23 | top_level_parent_pos = top_level_parent.pos() 24 | top_level_parent_geometry = top_level_parent.geometry() 25 | widget_pos = top_level_parent.mapToGlobal(widget.pos()) 26 | 27 | # Calculate available space for placements 28 | left_space = widget_pos.x() - top_level_parent_pos.x() 29 | right_space = top_level_parent_geometry.right() - (widget_pos.x() + widget.width()) 30 | top_space = widget_pos.y() - top_level_parent_pos.y() 31 | bottom_space = top_level_parent_geometry.bottom() - (widget_pos.y() + widget.height()) 32 | space_placement_map = { 33 | right_space: TooltipPlacement.RIGHT, 34 | left_space: TooltipPlacement.LEFT, 35 | top_space: TooltipPlacement.TOP, 36 | bottom_space: TooltipPlacement.BOTTOM 37 | } 38 | 39 | # Return most optimal placement that also fits on screen 40 | optimal_placement = None 41 | for space, placement in sorted(space_placement_map.items(), reverse=True): 42 | if not optimal_placement: 43 | optimal_placement = placement 44 | 45 | tooltip_rect = PlacementUtils.__get_tooltip_rect( 46 | widget, placement, size, triangle_size, offsets 47 | ) 48 | if PlacementUtils.__rect_contained_by_screen(tooltip_rect): 49 | return placement 50 | 51 | return optimal_placement 52 | 53 | @staticmethod 54 | def get_fallback_placement(widget: QWidget, primary_placement: TooltipPlacement, fallback_placements: 55 | list[TooltipPlacement], size: QSize, triangle_size: int, offsets: 56 | dict[TooltipPlacement, QPoint]) -> TooltipPlacement | None: 57 | """Calculate fallback placement if the current placement would 58 | lead to a tooltip that doesn't entirely fit on the screen 59 | 60 | :param widget: widget of the tooltip 61 | :param primary_placement: primary placement of the tooltip 62 | :param fallback_placements: fallback placements that are available 63 | :param size: size of the tooltip 64 | :param triangle_size: size of the triangle 65 | :param offsets: offsets of the tooltip 66 | :return: fallback placement (None if current placement is valid) 67 | """ 68 | 69 | tooltip_rect = PlacementUtils.__get_tooltip_rect( 70 | widget, primary_placement, size, triangle_size, offsets 71 | ) 72 | 73 | # Return None if current placement is valid 74 | if PlacementUtils.__rect_contained_by_screen(tooltip_rect): 75 | return None 76 | 77 | # Check all fallback placements and return first valid placement 78 | for placement in fallback_placements: 79 | if placement == primary_placement or placement == TooltipPlacement.AUTO: 80 | continue 81 | tooltip_rect = PlacementUtils.__get_tooltip_rect( 82 | widget, placement, size, triangle_size, offsets 83 | ) 84 | if PlacementUtils.__rect_contained_by_screen(tooltip_rect): 85 | return placement 86 | return None 87 | 88 | @staticmethod 89 | def __rect_contained_by_screen(rect: QRect) -> bool: 90 | """Check if a rect is fully contained by a single screen 91 | 92 | :param rect: rect that should be checked 93 | :return: whether the rect is contained by a screen 94 | """ 95 | 96 | for screen in QApplication.screens(): 97 | if screen.geometry().contains(rect): 98 | return True 99 | return False 100 | 101 | @staticmethod 102 | def __get_tooltip_rect(widget: QWidget, placement: TooltipPlacement, size: QSize, 103 | triangle_size: int, offsets: dict[TooltipPlacement, QPoint]) -> QRect: 104 | """Get the rect of a tooltip based on the widget position, 105 | placement, size, triangle size, and offsets of the tooltip 106 | 107 | :param widget: widget of the tooltip 108 | :param placement: placement of the tooltip 109 | :param size: size of the tooltip 110 | :param triangle_size: size of the triangle 111 | :param offsets: offsets of the tooltip 112 | :return: rect of the tooltip 113 | """ 114 | 115 | top_level_parent = Utils.get_top_level_parent(widget) 116 | widget_pos = top_level_parent.mapToGlobal(widget.pos()) 117 | rect = QRect() 118 | 119 | # Calculate rect depending on placement 120 | if placement == TooltipPlacement.TOP: 121 | rect.setX(int(widget_pos.x() + widget.width() / 2 - size.width() / 2) + offsets[placement].x()) 122 | rect.setY(widget_pos.y() - size.height() - triangle_size + offsets[placement].y()) 123 | rect.setRight(rect.x() + size.width()) 124 | rect.setBottom(rect.y() + size.height() + triangle_size) 125 | elif placement == TooltipPlacement.BOTTOM: 126 | rect.setX(int(widget_pos.x() + widget.width() / 2 - size.width() / 2) + offsets[placement].x()) 127 | rect.setY(widget_pos.y() + widget.height() + offsets[placement].y()) 128 | rect.setRight(rect.x() + size.width()) 129 | rect.setBottom(rect.y() + size.height() + triangle_size) 130 | elif placement == TooltipPlacement.LEFT: 131 | rect.setX(widget_pos.x() - size.width() - triangle_size + offsets[placement].x()) 132 | rect.setY(int(widget_pos.y() + widget.height() / 2 - size.width() / 2) + offsets[placement].y()) 133 | rect.setRight(rect.x() + size.width() + triangle_size) 134 | rect.setBottom(rect.y() + size.height()) 135 | elif placement == TooltipPlacement.RIGHT: 136 | rect.setX(widget_pos.x() + widget.width() + offsets[placement].x()) 137 | rect.setY(int(widget_pos.y() + widget.height() / 2 - size.width() / 2) + offsets[placement].y()) 138 | rect.setRight(rect.x() + size.width() + triangle_size) 139 | rect.setBottom(rect.y() + size.height()) 140 | 141 | return rect 142 | -------------------------------------------------------------------------------- /src/pyqttooltip/tooltip.py: -------------------------------------------------------------------------------- 1 | import math 2 | from qtpy.QtWidgets import QWidget, QLabel, QGraphicsOpacityEffect 3 | from qtpy.QtCore import ( 4 | Qt, Signal, QMargins, QPoint, QSize, QTimer, 5 | QPropertyAnimation, QEasingCurve, QEvent, QObject 6 | ) 7 | from qtpy.QtGui import QColor, QFont 8 | from .tooltip_interface import TooltipInterface 9 | from .tooltip_triangle import TooltipTriangle 10 | from .enums import TooltipPlacement 11 | from .drop_shadow import DropShadow 12 | from .placement_utils import PlacementUtils 13 | from .utils import Utils 14 | from .constants import * 15 | 16 | 17 | class Tooltip(TooltipInterface): 18 | 19 | # Signals 20 | shown = Signal() 21 | hidden = Signal() 22 | 23 | def __init__(self, widget: QWidget = None, text: str = ''): 24 | """Create a new Tooltip instance 25 | 26 | :param widget: widget to show the tooltip for 27 | :param text: text that will be displayed on the tooltip 28 | """ 29 | 30 | super(Tooltip, self).__init__(None) 31 | 32 | # Init attributes 33 | self.__widget = widget 34 | self.__text = text 35 | self.__duration = 0 36 | self.__placement = TooltipPlacement.AUTO 37 | self.__fallback_placements = [] 38 | self.__triangle_enabled = True 39 | self.__triangle_size = 5 40 | self.__offsets = { 41 | TooltipPlacement.LEFT: QPoint(0, 0), 42 | TooltipPlacement.RIGHT: QPoint(0, 0), 43 | TooltipPlacement.TOP: QPoint(0, 0), 44 | TooltipPlacement.BOTTOM: QPoint(0, 0) 45 | } 46 | self.__show_delay = 50 47 | self.__hide_delay = 50 48 | self.__fade_in_duration = 150 49 | self.__fade_out_duration = 150 50 | self.__fade_in_easing_curve = QEasingCurve.Type.Linear 51 | self.__fade_out_easing_curve = QEasingCurve.Type.Linear 52 | self.__text_centering_enabled = True 53 | self.__border_radius = 2 54 | self.__border_enabled = False 55 | self.__background_color = QColor('#111214') 56 | self.__text_color = QColor('#CFD2D5') 57 | self.__border_color = QColor('#403E41') 58 | self.__font = QFont('Arial', 9, QFont.Weight.Bold) 59 | self.__margins = QMargins(12, 8, 12, 7) 60 | self.__drop_shadow_enabled = True 61 | self.__drop_shadow_strength = 2.0 62 | self.__showing_on_disabled = False 63 | self.__maximum_width = QWIDGETSIZE_MAX 64 | 65 | self.__actual_placement = None 66 | self.__current_opacity = 0.0 67 | self.__watched_widgets = [] 68 | 69 | # Widget settings 70 | self.setWindowFlags( 71 | Qt.WindowType.ToolTip | 72 | Qt.WindowType.FramelessWindowHint | 73 | Qt.WindowType.WindowTransparentForInput 74 | ) 75 | self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) 76 | self.setFocusPolicy(Qt.FocusPolicy.NoFocus) 77 | 78 | # Opacity effect for fading animations 79 | self.__opacity_effect = QGraphicsOpacityEffect() 80 | self.setGraphicsEffect(self.__opacity_effect) 81 | 82 | # Create widgets 83 | self.__drop_shadow_widget = DropShadow(self) 84 | self.__tooltip_body = QLabel(self) 85 | self.__triangle_widget = TooltipTriangle(self) 86 | 87 | self.__text_widget = QLabel(self.__tooltip_body) 88 | self.__text_widget.setText(text) 89 | self.__text_widget.setFont(self.__font) 90 | self.__text_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) 91 | 92 | # Init delay timers 93 | self.__show_delay_timer = QTimer(self) 94 | self.__show_delay_timer.setInterval(self.__show_delay) 95 | self.__show_delay_timer.setSingleShot(True) 96 | self.__show_delay_timer.timeout.connect(self.__start_fade_in) 97 | 98 | self.__hide_delay_timer = QTimer(self) 99 | self.__hide_delay_timer.setInterval(self.__hide_delay) 100 | self.__hide_delay_timer.setSingleShot(True) 101 | self.__hide_delay_timer.timeout.connect(self.__start_fade_out) 102 | 103 | # Init duration timer 104 | self.__duration_timer = QTimer(self) 105 | self.__duration_timer.setInterval(self.__duration) 106 | self.__duration_timer.setSingleShot(True) 107 | self.__duration_timer.timeout.connect(self.__start_fade_out) 108 | 109 | # Init fade animations 110 | self.__fade_in_animation = QPropertyAnimation(self.__opacity_effect, b'opacity') 111 | self.__fade_in_animation.setDuration(self.__fade_in_duration) 112 | self.__fade_in_animation.setEasingCurve(self.__fade_in_easing_curve) 113 | self.__fade_in_animation.valueChanged.connect(self.__update_current_opacity) 114 | self.__fade_in_animation.finished.connect(self.__start_duration_timer) 115 | 116 | self.__fade_out_animation = QPropertyAnimation(self.__opacity_effect, b'opacity') 117 | self.__fade_out_animation.setDuration(self.__fade_out_duration) 118 | self.__fade_out_animation.setEasingCurve(self.__fade_out_easing_curve) 119 | self.__fade_out_animation.valueChanged.connect(self.__update_current_opacity) 120 | self.__fade_out_animation.finished.connect(self.__hide) 121 | 122 | # Init stylesheet and event filters 123 | self.__update_stylesheet() 124 | self.__install_event_filters() 125 | 126 | def eventFilter(self, watched: QObject, event: QEvent) -> bool: 127 | """Event filter that watched widget and all of its parents 128 | and updates the tooltip or event filters when necessary 129 | 130 | :param watched: object that is watched 131 | :param event: event that is received 132 | :return: whether further processing of the event is stopped 133 | """ 134 | 135 | if event.type() == event.Type.HoverEnter and watched == self.__widget: 136 | # Mouse enters widget 137 | if self.__widget and self.__widget.isEnabled(): 138 | self.show(delay=True) 139 | elif self.__widget and not self.__widget.isEnabled() and self.__showing_on_disabled: 140 | self.show(delay=True) 141 | elif event.type() == event.Type.HoverLeave and watched == self.__widget: 142 | # Mouse leaves widget 143 | self.hide(delay=True) 144 | 145 | # Widget or parent moved, resized, shown or hidden 146 | if (event.type() == event.Type.Move or event.type() == event.Type.Resize 147 | or event.type() == event.Type.Show or event.type() == event.Type.Hide): 148 | self.__update_ui() 149 | 150 | # One of the parents changed 151 | if event.type() == event.Type.ParentChange: 152 | self.__install_event_filters() 153 | 154 | # Parent or widget deleted 155 | if event.type() == event.Type.DeferredDelete: 156 | self.__install_event_filters() 157 | self.hide() 158 | if watched == self.__widget: 159 | self.__widget = None 160 | return False 161 | 162 | def getWidget(self) -> QWidget: 163 | """Get the widget that triggers the tooltip 164 | 165 | :return: widget 166 | """ 167 | 168 | return self.__widget 169 | 170 | def setWidget(self, widget: QWidget): 171 | """Set the widget that triggers the tooltip 172 | 173 | :param widget: new widget 174 | """ 175 | 176 | if self.__current_opacity != 0: 177 | super().hide() 178 | self.__widget = widget 179 | self.__install_event_filters() 180 | 181 | def getText(self) -> str: 182 | """Get the text of the tooltip 183 | 184 | :return: text 185 | """ 186 | 187 | return self.__text 188 | 189 | def setText(self, text: str): 190 | """Set the text of the tooltip 191 | 192 | :param text: new text 193 | """ 194 | 195 | self.__text = text 196 | self.__text_widget.setText(text) 197 | self.__update_ui() 198 | 199 | def getDuration(self) -> int: 200 | """Get the duration of the tooltip. If the duration is 0, 201 | the tooltip will stay open until the mouse leaves the widget. 202 | 203 | :return: duration 204 | """ 205 | 206 | return self.__duration 207 | 208 | def setDuration(self, duration: int): 209 | """Set the duration of the tooltip. If the duration is 0, 210 | the tooltip will stay open until the mouse leaves the widget. 211 | 212 | :param duration: new duration 213 | """ 214 | 215 | self.__duration = duration 216 | self.__duration_timer.setInterval(duration) 217 | 218 | def getPlacement(self) -> TooltipPlacement: 219 | """Get the placement of the tooltip 220 | 221 | :return: placement 222 | """ 223 | 224 | return self.__placement 225 | 226 | def setPlacement(self, placement: TooltipPlacement): 227 | """Set the placement of the tooltip 228 | 229 | :param placement: new placement 230 | """ 231 | 232 | self.__placement = placement 233 | self.__update_ui() 234 | 235 | def getActualPlacement(self) -> TooltipPlacement: 236 | """Get the actual placement of the tooltip. This will be different 237 | from the placement if the placement is TooltipPlacement.AUTO 238 | or the tooltip is shown in a fallback placement. 239 | 240 | :return: actual placement (LEFT / RIGHT / TOP / BOTTOM) 241 | """ 242 | 243 | return self.__actual_placement 244 | 245 | def getFallbackPlacements(self) -> list[TooltipPlacement]: 246 | """Get the fallback placements of the tooltip. If the tooltip 247 | doesn't fit on the screen with the main placement, one of the 248 | fallback placements will be chosen instead. 249 | 250 | :return: fallback placements 251 | """ 252 | 253 | return self.__fallback_placements 254 | 255 | def setFallbackPlacements(self, fallback_placements: list[TooltipPlacement]): 256 | """Set the fallback placements of the tooltip. If the tooltip 257 | doesn't fit on the screen with the main placement, one of the 258 | fallback placements will be chosen instead. 259 | 260 | :param fallback_placements: new fallback placements 261 | """ 262 | 263 | self.__fallback_placements = fallback_placements 264 | self.__update_ui() 265 | 266 | def isTriangleEnabled(self) -> bool: 267 | """Get whether the triangle is enabled 268 | 269 | :return: whether the triangle is enabled 270 | """ 271 | 272 | return self.__triangle_enabled 273 | 274 | def setTriangleEnabled(self, enabled: bool): 275 | """Get whether the triangle should be enabled 276 | 277 | :param enabled: whether the triangle should be enabled 278 | """ 279 | 280 | self.__triangle_enabled = enabled 281 | self.__update_ui() 282 | 283 | def getTriangleSize(self) -> int: 284 | """Get the size of the triangle 285 | 286 | :return: size 287 | """ 288 | 289 | return self.__triangle_size 290 | 291 | def setTriangleSize(self, size: int): 292 | """Set the size of the triangle 293 | 294 | :param size: new size 295 | """ 296 | 297 | self.__triangle_size = size 298 | self.__update_ui() 299 | 300 | def getOffsets(self) -> dict[TooltipPlacement, QPoint]: 301 | """Get the offsets of the tooltip 302 | 303 | :return: offsets 304 | """ 305 | 306 | return self.__offsets 307 | 308 | def getOffsetByPlacement(self, placement: TooltipPlacement) -> QPoint: 309 | """Get a specific offset of the tooltip 310 | 311 | :param placement: placement to get the offset for 312 | :return: offset 313 | """ 314 | 315 | return self.__offsets[placement] 316 | 317 | def setOffsets(self, offsets: dict[TooltipPlacement, QPoint]): 318 | """Set the offsets of the tooltip individually 319 | 320 | :param offsets: dict with placements as the keys and offsets as values 321 | """ 322 | 323 | for placement, offset in offsets.items(): 324 | self.__offsets[placement] = offset 325 | self.__update_ui() 326 | 327 | def setOffsetByPlacement(self, placement: TooltipPlacement, offset: QPoint): 328 | """Set a specific offset of the tooltip 329 | 330 | :param placement: placement to set the offset for 331 | :param offset: new offset 332 | """ 333 | 334 | self.__offsets[placement] = offset 335 | self.__update_ui() 336 | 337 | def setOffsetsAll(self, offset: QPoint): 338 | """Set the offsets of all the placements to a value 339 | 340 | :param offset: new offset for all the placements 341 | """ 342 | 343 | for placement, _ in self.__offsets.items(): 344 | self.__offsets[placement] = offset 345 | self.__update_ui() 346 | 347 | def getShowDelay(self) -> int: 348 | """Get the delay before the tooltip is starting to fade in 349 | 350 | :return: delay 351 | """ 352 | 353 | return self.__show_delay 354 | 355 | def setShowDelay(self, delay: int): 356 | """Set the delay before the tooltip is starting to fade in 357 | 358 | :param delay: new delay 359 | """ 360 | 361 | self.__show_delay = delay 362 | self.__show_delay_timer.setInterval(delay) 363 | 364 | def getHideDelay(self) -> int: 365 | """Get the delay before the tooltip is starting to fade out 366 | 367 | :return: delay 368 | """ 369 | 370 | return self.__hide_delay 371 | 372 | def setHideDelay(self, delay: int): 373 | """Set the delay before the tooltip is starting to fade out 374 | 375 | :param delay: new delay 376 | """ 377 | 378 | self.__hide_delay = delay 379 | self.__hide_delay_timer.setInterval(delay) 380 | 381 | def getFadeInDuration(self) -> int: 382 | """Get the duration of the fade in animation 383 | 384 | :return: duration 385 | """ 386 | 387 | return self.__fade_in_duration 388 | 389 | def setFadeInDuration(self, duration: int): 390 | """Set the duration of the fade in animation 391 | 392 | :param duration: new duration 393 | """ 394 | 395 | self.__fade_in_duration = duration 396 | self.__fade_in_animation.setStartValue(self.__current_opacity) 397 | self.__fade_in_animation.setDuration(duration) 398 | 399 | def getFadeOutDuration(self) -> int: 400 | """Get the duration of the fade out animation 401 | 402 | :return: duration 403 | """ 404 | 405 | return self.__fade_out_duration 406 | 407 | def setFadeOutDuration(self, duration: int): 408 | """Set the duration of the fade out animation 409 | 410 | :param duration: new duration 411 | """ 412 | 413 | self.__fade_out_duration = duration 414 | self.__fade_out_animation.setStartValue(self.__current_opacity) 415 | self.__fade_out_animation.setDuration(duration) 416 | 417 | def getFadeInEasingCurve(self) -> QEasingCurve.Type: 418 | """Get the easing curve of the fade in animation 419 | 420 | :return: easing curve 421 | """ 422 | 423 | return self.__fade_in_easing_curve 424 | 425 | def setFadeInEasingCurve(self, easing_curve: QEasingCurve.Type | None): 426 | """Set the easing curve of the fade in animation 427 | 428 | :param easing_curve: new easing curve (or None) 429 | """ 430 | 431 | if easing_curve is None: 432 | easing_curve = QEasingCurve.Type.Linear 433 | 434 | self.__fade_in_easing_curve = easing_curve 435 | self.__fade_in_animation.setEasingCurve(easing_curve) 436 | 437 | def getFadeOutEasingCurve(self) -> QEasingCurve.Type: 438 | """Get the easing curve of the fade out animation 439 | 440 | :return: easing curve 441 | """ 442 | 443 | return self.__fade_out_easing_curve 444 | 445 | def setFadeOutEasingCurve(self, easing_curve: QEasingCurve.Type | None): 446 | """Set the easing curve of the fade out animation 447 | 448 | :param easing_curve: new easing curve (or None) 449 | """ 450 | 451 | if easing_curve is None: 452 | easing_curve = QEasingCurve.Type.Linear 453 | 454 | self.__fade_out_easing_curve = easing_curve 455 | self.__fade_out_animation.setEasingCurve(easing_curve) 456 | 457 | def isTextCenteringEnabled(self) -> bool: 458 | """Get whether text centering is enabled 459 | 460 | :return: whether text centering is enabled 461 | """ 462 | 463 | return self.__text_centering_enabled 464 | 465 | def setTextCenteringEnabled(self, enabled: bool): 466 | """Set whether text centering should be enabled 467 | 468 | :param enabled: whether text centering should be enabled 469 | """ 470 | 471 | self.__text_centering_enabled = enabled 472 | if enabled: 473 | self.__text_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) 474 | else: 475 | self.__text_widget.setAlignment(Qt.AlignmentFlag.AlignLeft) 476 | self.__update_ui() 477 | 478 | def getBorderRadius(self) -> int: 479 | """Get the border radius of the tooltip 480 | 481 | :return: border radius 482 | """ 483 | 484 | return self.__border_radius 485 | 486 | def setBorderRadius(self, border_radius: int): 487 | """Set the border radius of the tooltip 488 | 489 | :param border_radius: new border radius 490 | """ 491 | 492 | self.__border_radius = border_radius 493 | self.__update_stylesheet() 494 | self.__update_ui() 495 | 496 | def isBorderEnabled(self) -> bool: 497 | """Get whether the border is enabled 498 | 499 | :return: whether the border is enabled 500 | """ 501 | 502 | return self.__border_enabled 503 | 504 | def setBorderEnabled(self, enabled: bool): 505 | """Set whether the border should be enabled 506 | 507 | :param enabled: whether the border should be enabled 508 | """ 509 | 510 | self.__border_enabled = enabled 511 | self.__update_stylesheet() 512 | self.__update_ui() 513 | 514 | def getBackgroundColor(self) -> QColor: 515 | """Get the background color of the tooltip 516 | 517 | :return: background color 518 | """ 519 | 520 | return self.__background_color 521 | 522 | def setBackgroundColor(self, color: QColor): 523 | """Set the background color of the tooltip 524 | 525 | :param color: new background color 526 | """ 527 | 528 | self.__background_color = color 529 | self.__update_stylesheet() 530 | self.__update_ui() 531 | 532 | def getTextColor(self) -> QColor: 533 | """Get the text color of the tooltip 534 | 535 | :return: text color 536 | """ 537 | 538 | return self.__text_color 539 | 540 | def setTextColor(self, color: QColor): 541 | """Set the text color of the tooltip 542 | 543 | :param color: new text color 544 | """ 545 | 546 | self.__text_color = color 547 | self.__update_stylesheet() 548 | self.__update_ui() 549 | 550 | def getBorderColor(self) -> QColor: 551 | """Get the border color of the tooltip 552 | 553 | :return: border color 554 | """ 555 | 556 | return self.__border_color 557 | 558 | def setBorderColor(self, color: QColor): 559 | """Set the border color of the tooltip 560 | 561 | :param color: new border color 562 | """ 563 | 564 | self.__border_color = color 565 | self.__update_stylesheet() 566 | self.__update_ui() 567 | 568 | def getOpacity(self) -> float: 569 | """Get the opacity of the tooltip 570 | 571 | :return: opacity 572 | """ 573 | 574 | return self.windowOpacity() 575 | 576 | def setOpacity(self, opacity: float): 577 | """Set the opacity of the tooltip 578 | 579 | :param opacity: new opacity 580 | """ 581 | 582 | self.setWindowOpacity(opacity) 583 | 584 | def font(self) -> QFont: 585 | """Get the font of the tooltip 586 | 587 | :return: font 588 | """ 589 | 590 | return self.getFont() 591 | 592 | def getFont(self) -> QFont: 593 | """Get the font of the tooltip 594 | 595 | :return: font 596 | """ 597 | 598 | return self.__font 599 | 600 | def setFont(self, font: QFont): 601 | """Set the font of the tooltip 602 | 603 | :param font: new font 604 | """ 605 | 606 | self.__font = font 607 | self.__text_widget.setFont(font) 608 | self.__update_ui() 609 | 610 | def getMargins(self) -> QMargins: 611 | """Get the margins of the tooltip 612 | 613 | :return: margins 614 | """ 615 | 616 | return self.__margins 617 | 618 | def setMargins(self, margins: QMargins): 619 | """Get the margins of the tooltip 620 | 621 | :param margins: new margins 622 | """ 623 | 624 | self.__margins = margins 625 | self.__update_ui() 626 | 627 | def setMarginLeft(self, margin: int): 628 | """Set the left margin of the tooltip 629 | 630 | :param margin: new margin 631 | """ 632 | 633 | self.__margins.setLeft(margin) 634 | self.__update_ui() 635 | 636 | def setMarginTop(self, margin: int): 637 | """Set the top margin of the tooltip 638 | 639 | :param margin: new margin 640 | """ 641 | 642 | self.__margins.setTop(margin) 643 | self.__update_ui() 644 | 645 | def setMarginRight(self, margin: int): 646 | """Set the right margin of the tooltip 647 | 648 | :param margin: new margin 649 | """ 650 | 651 | self.__margins.setRight(margin) 652 | self.__update_ui() 653 | 654 | def setMarginBottom(self, margin: int): 655 | """Set the bottom margin of the tooltip 656 | 657 | :param margin: new margin 658 | """ 659 | 660 | self.__margins.setBottom(margin) 661 | self.__update_ui() 662 | 663 | def isDropShadowEnabled(self) -> bool: 664 | """Get whether the drop shadow is enabled 665 | 666 | :return: whether the drop shadow is enabled 667 | """ 668 | 669 | return self.__drop_shadow_enabled 670 | 671 | def setDropShadowEnabled(self, enabled: bool): 672 | """Set whether the drop shadow should be enabled 673 | 674 | :param enabled: whether the drop shadow should be enabled 675 | """ 676 | 677 | self.__drop_shadow_enabled = enabled 678 | self.__update_ui() 679 | 680 | def getDropShadowStrength(self) -> float: 681 | """Get the strength of the drop shadow 682 | 683 | :return: strength 684 | """ 685 | 686 | return self.__drop_shadow_strength 687 | 688 | def setDropShadowStrength(self, strength: float): 689 | """Set the strength of the drop shadow 690 | 691 | :param strength: new strength 692 | """ 693 | 694 | self.__drop_shadow_strength = strength 695 | self.__drop_shadow_widget.update() 696 | 697 | def isShowingOnDisabled(self) -> bool: 698 | """Get whether the tooltip will also be shown on disabled widgets 699 | 700 | :return: whether the tooltip will also be shown on disabled widgets 701 | """ 702 | 703 | return self.__showing_on_disabled 704 | 705 | def setShowingOnDisabled(self, on: bool): 706 | """Set whether the tooltip should also be shown on disabled widgets 707 | 708 | :param on: whether the tooltip should also be shown on disabled widgets 709 | """ 710 | 711 | self.__showing_on_disabled = on 712 | 713 | def maximumSize(self) -> QSize: 714 | """Get the maximum size of the tooltip 715 | 716 | :return: maximum size 717 | """ 718 | 719 | return QSize(self.__maximum_width, self.maximumHeight()) 720 | 721 | def setMaximumSize(self, max_size: QSize): 722 | """Set the maximum size of the tooltip 723 | 724 | :param max_size: new maximum size 725 | """ 726 | 727 | self.__maximum_width = max_size.width() 728 | self.setMaximumHeight(max_size.height()) 729 | self.__update_ui() 730 | 731 | def maximumWidth(self) -> int: 732 | """Get the maximum width of the tooltip 733 | 734 | :return: maximum width 735 | """ 736 | 737 | return self.__maximum_width 738 | 739 | def setMaximumWidth(self, max_width: int): 740 | """Set the maximum width of the tooltip 741 | 742 | :param max_width: new maximum width 743 | """ 744 | 745 | self.__maximum_width = max_width 746 | self.__update_ui() 747 | 748 | def show(self, delay: bool = False): 749 | """Start the process of showing the tooltip 750 | 751 | :param delay: whether the tooltip should be shown with the delay (default: False) 752 | """ 753 | 754 | self.__duration_timer.stop() 755 | self.__update_ui() 756 | 757 | if delay: 758 | self.__start_show_delay() 759 | else: 760 | self.__start_fade_in() 761 | 762 | def hide(self, delay: bool = False): 763 | """Start the process of hiding the tooltip 764 | 765 | :param delay: whether the tooltip should be hidden with the delay (default: False) 766 | """ 767 | 768 | if delay: 769 | self.__start_hide_delay() 770 | else: 771 | self.__start_fade_out() 772 | 773 | def update(self): 774 | """Update the tooltip""" 775 | 776 | self.__update_ui() 777 | super().update() 778 | 779 | def __start_show_delay(self): 780 | """Start a delay that will start the fade in animation when finished""" 781 | 782 | self.__hide_delay_timer.stop() 783 | self.__show_delay_timer.start() 784 | 785 | def __start_fade_in(self): 786 | """Start the fade in animation""" 787 | 788 | # Emit shown signal if currently hidden 789 | if self.__current_opacity == 0.0: 790 | self.shown.emit() 791 | 792 | # Start fade in animation and show 793 | self.__fade_in_animation.setStartValue(self.__current_opacity) 794 | self.__fade_in_animation.setEndValue(1) 795 | self.__fade_in_animation.start() 796 | super().show() 797 | 798 | def __start_duration_timer(self): 799 | """Start the duration timer that hides the tooltip after 800 | a specific amount of time if enabled""" 801 | 802 | if self.__duration != 0: 803 | self.__duration_timer.start() 804 | 805 | def __start_hide_delay(self): 806 | """Start a delay that will start the fade out animation when finished""" 807 | 808 | self.__show_delay_timer.stop() 809 | self.__hide_delay_timer.start() 810 | 811 | def __start_fade_out(self): 812 | """Start the fade out animation""" 813 | 814 | self.__fade_out_animation.setStartValue(self.__current_opacity) 815 | self.__fade_out_animation.setEndValue(0) 816 | self.__fade_out_animation.start() 817 | 818 | def __hide(self): 819 | """Hide the tooltip""" 820 | 821 | self.__duration_timer.stop() 822 | super().hide() 823 | self.hidden.emit() 824 | 825 | def __update_current_opacity(self, value: float): 826 | """Update the current_opacity attribute with the new value of the animation 827 | 828 | :param value: value received by the valueChanged event 829 | """ 830 | 831 | self.__current_opacity = value 832 | 833 | def __update_stylesheet(self): 834 | """Update the stylesheet of the widgets that are part of the tooltip""" 835 | 836 | self.__tooltip_body.setStyleSheet( 837 | 'background: {}; ' 838 | 'border-radius: {}px; ' 839 | 'border: {}px solid {};' 840 | .format( 841 | self.__background_color.name(), 842 | self.__border_radius, 843 | 1 if self.__border_enabled else 0, 844 | self.__border_color.name() 845 | ) 846 | ) 847 | self.__text_widget.setStyleSheet( 848 | 'border: none;' 849 | 'color: {}'.format(self.__text_color.name()) 850 | ) 851 | 852 | def __update_ui(self): 853 | """Update the UI of the tooltip""" 854 | 855 | if not self.__widget: 856 | return 857 | 858 | # Calculate text width and height 859 | self.__text_widget.setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX) 860 | font_metrics = self.__text_widget.fontMetrics() 861 | bounding_rect = font_metrics.boundingRect(self.__text) 862 | text_size = QSize(bounding_rect.width() + 2, bounding_rect.height()) 863 | 864 | # Calculate body width and height 865 | body_size = QSize( 866 | self.__margins.left() + text_size.width() + self.__margins.right(), 867 | self.__margins.top() + text_size.height() + self.__margins.bottom() 868 | ) 869 | 870 | # Handle width greater than maximum width 871 | if body_size.width() > self.__maximum_width: 872 | self.__text_widget.setWordWrap(True) 873 | text_size.setWidth(self.__maximum_width - self.__margins.left() - self.__margins.right()) 874 | text_size.setHeight(self.__text_widget.heightForWidth(text_size.width())) 875 | 876 | # Minimize text width for calculated text height 877 | new_text_height = self.__text_widget.heightForWidth(text_size.width() - 1) 878 | new_text_width = text_size.width() 879 | while new_text_height == text_size.height(): 880 | new_text_width -= 1 881 | new_text_height = self.__text_widget.heightForWidth(new_text_width) 882 | text_size.setWidth(new_text_width + 1) 883 | 884 | # Recalculate body width and height 885 | body_size.setWidth(self.__margins.left() + text_size.width() + self.__margins.right()) 886 | body_size.setHeight(self.__margins.top() + text_size.height() + self.__margins.bottom()) 887 | 888 | # Calculate actual tooltip placement 889 | if self.__placement == TooltipPlacement.AUTO: 890 | self.__actual_placement = PlacementUtils.get_optimal_placement( 891 | self.__widget, body_size, self.__triangle_size, self.__offsets 892 | ) 893 | else: 894 | self.__actual_placement = self.__placement 895 | # Calculate fallback placement 896 | if self.__fallback_placements: 897 | fallback_placement = PlacementUtils.get_fallback_placement( 898 | self.__widget, self.__actual_placement, self.__fallback_placements, 899 | body_size, self.__triangle_size, self.__offsets 900 | ) 901 | if fallback_placement: 902 | self.__actual_placement = fallback_placement 903 | 904 | # Calculate total size and widget positions based on placement 905 | size = QSize(body_size.width(), body_size.height()) 906 | tooltip_triangle_pos = QPoint(0, 0) 907 | tooltip_body_pos = QPoint(0, 0) 908 | tooltip_pos = QPoint(0, 0) 909 | widget_pos = Utils.get_top_level_parent(self.__widget).mapToGlobal(self.__widget.pos()) 910 | border_width = 1 if self.__border_enabled else 0 911 | self.__triangle_widget.update() 912 | 913 | if self.__actual_placement == TooltipPlacement.TOP: 914 | size.setHeight(body_size.height() + self.__triangle_widget.height() - border_width) 915 | tooltip_triangle_pos.setX(math.ceil(size.width() / 2 - self.__triangle_size)) 916 | tooltip_triangle_pos.setY(body_size.height() - border_width) 917 | tooltip_pos.setX( 918 | int(widget_pos.x() + self.__widget.width() / 2 - size.width() / 2) 919 | + self.__offsets[self.__actual_placement].x() 920 | ) 921 | tooltip_pos.setY(widget_pos.y() - size.height() + self.__offsets[self.__actual_placement].y()) 922 | 923 | elif self.__actual_placement == TooltipPlacement.BOTTOM: 924 | size.setHeight(body_size.height() + self.__triangle_widget.height() - border_width) 925 | tooltip_triangle_pos.setX(math.ceil(size.width() / 2 - self.__triangle_size)) 926 | tooltip_body_pos.setY(self.__triangle_widget.height() - border_width) 927 | tooltip_pos.setX( 928 | int(widget_pos.x() + self.__widget.width() / 2 - size.width() / 2) 929 | + self.__offsets[self.__actual_placement].x() 930 | ) 931 | tooltip_pos.setY( 932 | widget_pos.y() + self.__widget.height() + self.__offsets[self.__actual_placement].y() 933 | ) 934 | 935 | elif self.__actual_placement == TooltipPlacement.LEFT: 936 | size.setWidth(body_size.width() + self.__triangle_widget.width() - border_width) 937 | tooltip_triangle_pos.setX(body_size.width() - border_width) 938 | tooltip_triangle_pos.setY(math.ceil(size.height() / 2 - self.__triangle_size)) 939 | tooltip_pos.setX(widget_pos.x() - size.width() + self.__offsets[self.__actual_placement].x()) 940 | tooltip_pos.setY( 941 | int(widget_pos.y() + self.__widget.height() / 2 - size.height() / 2) 942 | + self.__offsets[self.__actual_placement].y() 943 | ) 944 | 945 | elif self.__actual_placement == TooltipPlacement.RIGHT: 946 | size.setWidth(body_size.width() + self.__triangle_widget.width() - border_width) 947 | tooltip_triangle_pos.setY(math.ceil(size.height() / 2 - self.__triangle_size)) 948 | tooltip_body_pos.setX(self.__triangle_widget.width() - border_width) 949 | tooltip_pos.setX( 950 | widget_pos.x() + self.__widget.width() 951 | + self.__offsets[self.__actual_placement].x() 952 | ) 953 | tooltip_pos.setY( 954 | int(widget_pos.y() + self.__widget.height() / 2 - size.height() / 2) 955 | + self.__offsets[self.__actual_placement].y() 956 | ) 957 | 958 | # Move and resize widgets 959 | self.__text_widget.resize(text_size) 960 | self.__text_widget.move(self.__margins.left(), self.__margins.top()) 961 | self.__tooltip_body.resize(body_size) 962 | 963 | if self.__drop_shadow_enabled: 964 | # Adjust positions and sizes for drop shadow if enabled 965 | self.__tooltip_body.move( 966 | tooltip_body_pos.x() + DROP_SHADOW_SIZE, tooltip_body_pos.y() + DROP_SHADOW_SIZE 967 | ) 968 | self.__triangle_widget.move( 969 | tooltip_triangle_pos.x() + DROP_SHADOW_SIZE, tooltip_triangle_pos.y() + DROP_SHADOW_SIZE 970 | ) 971 | self.__drop_shadow_widget.resize( 972 | QSize(body_size.width() + DROP_SHADOW_SIZE * 2, body_size.height() + DROP_SHADOW_SIZE * 2) 973 | ) 974 | self.__drop_shadow_widget.move(tooltip_body_pos) 975 | self.__drop_shadow_widget.update() 976 | self.__drop_shadow_widget.setVisible(True) 977 | self.setFixedSize( 978 | max(size.width(), self.__drop_shadow_widget.width() + tooltip_body_pos.x()), 979 | max(size.height(), self.__drop_shadow_widget.height() + tooltip_body_pos.y()) 980 | ) 981 | self.move(tooltip_pos.x() - DROP_SHADOW_SIZE, tooltip_pos.y() - DROP_SHADOW_SIZE) 982 | else: 983 | self.__tooltip_body.move(tooltip_body_pos) 984 | self.__triangle_widget.move(tooltip_triangle_pos) 985 | self.setFixedSize(size) 986 | self.move(tooltip_pos) 987 | self.__drop_shadow_widget.setVisible(False) 988 | 989 | def __install_event_filters(self): 990 | """Install / reinstall event filters on widget and its parents""" 991 | 992 | self.__remove_event_filters() 993 | if not self.__widget: 994 | return 995 | self.__watched_widgets.append(self.__widget) 996 | self.__watched_widgets += Utils.get_parents(self.__widget) 997 | 998 | for widget in self.__watched_widgets: 999 | widget.installEventFilter(self) 1000 | 1001 | def __remove_event_filters(self): 1002 | """Remove installed event filters""" 1003 | 1004 | for widget in self.__watched_widgets: 1005 | widget.removeEventFilter(self) 1006 | self.__watched_widgets.clear() 1007 | -------------------------------------------------------------------------------- /src/pyqttooltip/tooltip_interface.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QWidget 2 | from qtpy.QtGui import QColor 3 | from .enums import TooltipPlacement 4 | 5 | 6 | class TooltipInterface(QWidget): 7 | 8 | def isTriangleEnabled(self) -> bool: 9 | pass 10 | 11 | def getTriangleSize(self) -> int: 12 | pass 13 | 14 | def getActualPlacement(self) -> TooltipPlacement | None: 15 | pass 16 | 17 | def isBorderEnabled(self) -> bool: 18 | pass 19 | 20 | def getBackgroundColor(self) -> QColor: 21 | pass 22 | 23 | def getBorderColor(self) -> QColor: 24 | pass 25 | 26 | def getDropShadowStrength(self) -> float: 27 | pass 28 | -------------------------------------------------------------------------------- /src/pyqttooltip/tooltip_triangle.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QWidget 2 | from qtpy.QtGui import QPainter 3 | from qtpy.QtCore import QPoint, QEvent 4 | from .tooltip_interface import TooltipInterface 5 | from .enums import TooltipPlacement 6 | 7 | 8 | class TooltipTriangle(QWidget): 9 | 10 | def __init__(self, tooltip: TooltipInterface): 11 | """Create a new TooltipTriangle instance 12 | 13 | :param tooltip: tooltip the triangle belongs to 14 | """ 15 | 16 | super(TooltipTriangle, self).__init__(tooltip) 17 | 18 | self.tooltip = tooltip 19 | 20 | def paintEvent(self, event: QEvent): 21 | """Paint event that paints the triangle based on the current 22 | settings of the tooltip 23 | 24 | :param event: event that is received 25 | """ 26 | 27 | # Ignore if triangle is disabled or actual placement not yet set 28 | if not self.tooltip.isTriangleEnabled(): 29 | return 30 | 31 | if self.tooltip.getActualPlacement() is None: 32 | return 33 | 34 | # Get parameters 35 | size = self.tooltip.getTriangleSize() 36 | actual_placement = self.tooltip.getActualPlacement() 37 | background_color = self.tooltip.getBackgroundColor() 38 | border_color = self.tooltip.getBorderColor() 39 | border_enabled = self.tooltip.isBorderEnabled() 40 | border_width = 1 if border_enabled else 0 41 | 42 | # Init painter 43 | painter = QPainter() 44 | painter.begin(self) 45 | painter.setPen(border_color if border_enabled else background_color) 46 | 47 | # Draw triangle shape depending on tooltip placement 48 | if actual_placement == TooltipPlacement.RIGHT: 49 | start = QPoint(0, size - 1) 50 | painter.drawPoint(start) 51 | 52 | for i in range(1, size + border_width): 53 | painter.setPen(background_color) 54 | painter.drawLine( 55 | QPoint(start.x() + i, start.y() - i), QPoint(start.x() + i, start.y() + i) 56 | ) 57 | if border_width > 0: 58 | painter.setPen(border_color) 59 | painter.drawPoint(start.x() + i, start.y() - i) 60 | painter.drawPoint(start.x() + i, start.y() + i) 61 | 62 | elif actual_placement == TooltipPlacement.LEFT: 63 | start = QPoint(size - 1 + border_width, size - 1) 64 | painter.drawPoint(start) 65 | 66 | for i in range(1, size + border_width): 67 | painter.setPen(background_color) 68 | painter.drawLine( 69 | QPoint(start.x() - i, start.y() - i), QPoint(start.x() - i, start.y() + i) 70 | ) 71 | if border_width > 0: 72 | painter.setPen(border_color) 73 | painter.drawPoint(start.x() - i, start.y() - i) 74 | painter.drawPoint(start.x() - i, start.y() + i) 75 | 76 | elif actual_placement == TooltipPlacement.TOP: 77 | start = QPoint(size - 1, size - 1 + border_width) 78 | painter.drawPoint(start) 79 | 80 | for i in range(1, size + border_width): 81 | painter.setPen(background_color) 82 | painter.drawLine( 83 | QPoint(start.x() - i, start.y() - i), QPoint(start.x() + i, start.y() - i) 84 | ) 85 | if border_width > 0: 86 | painter.setPen(border_color) 87 | painter.drawPoint(start.x() - i, start.y() - i) 88 | painter.drawPoint(start.x() + i, start.y() - i) 89 | 90 | elif actual_placement == TooltipPlacement.BOTTOM: 91 | start = QPoint(size - 1, 0) 92 | painter.drawPoint(start) 93 | 94 | for i in range(1, size + border_width): 95 | painter.setPen(background_color) 96 | painter.drawLine( 97 | QPoint(start.x() - i, start.y() + i), QPoint(start.x() + i, start.y() + i) 98 | ) 99 | if border_width > 0: 100 | painter.setPen(border_color) 101 | painter.drawPoint(start.x() - i, start.y() + i) 102 | painter.drawPoint(start.x() + i, start.y() + i) 103 | 104 | painter.end() 105 | 106 | def update(self): 107 | """Update the size of the triangle and call the paint event""" 108 | 109 | # Get parameters 110 | enabled = self.tooltip.isTriangleEnabled() 111 | size = self.tooltip.getTriangleSize() 112 | actual_placement = self.tooltip.getActualPlacement() 113 | border_width = 1 if self.tooltip.isBorderEnabled() > 0 else 0 114 | 115 | # Resize depending on placement 116 | if enabled: 117 | if actual_placement == TooltipPlacement.BOTTOM or actual_placement == TooltipPlacement.TOP: 118 | self.resize(size * 2 - 1, size + border_width) 119 | elif actual_placement == TooltipPlacement.LEFT or actual_placement == TooltipPlacement.RIGHT: 120 | self.resize(size + border_width, size * 2 - 1) 121 | else: 122 | self.resize(0, 0) 123 | 124 | # Fire paint event 125 | super().update() 126 | -------------------------------------------------------------------------------- /src/pyqttooltip/utils.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QWidget 2 | 3 | 4 | class Utils: 5 | 6 | @staticmethod 7 | def get_top_level_parent(widget: QWidget) -> QWidget: 8 | """Get the top level parent of a widget. If the widget has no parent, 9 | the widget itself is considered the top level parent. 10 | 11 | :param widget: widget to calculate the top level parent on 12 | :return: top level parents 13 | """ 14 | 15 | if widget.parent() is None: 16 | return widget 17 | 18 | parent = widget.parent() 19 | 20 | while parent.parent() is not None: 21 | parent = parent.parent() 22 | return parent 23 | 24 | @staticmethod 25 | def get_parents(widget: QWidget) -> list[QWidget]: 26 | """Get all the parents of a widget 27 | 28 | :param widget: the widget to get the parents of 29 | :return: parents of the widget 30 | """ 31 | 32 | parents = [] 33 | 34 | while widget.parent() is not None: 35 | parents.append(widget.parent()) 36 | widget = widget.parent() 37 | return parents 38 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is needed for the tests be able to run properly 2 | -------------------------------------------------------------------------------- /tests/placement_utils_test.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QMainWindow, QPushButton 2 | from PyQt6.QtCore import QPoint, QSize 3 | from src.pyqttooltip import TooltipPlacement 4 | from src.pyqttooltip.placement_utils import PlacementUtils 5 | 6 | 7 | def test_get_optimal_placement(qtbot): 8 | """Test getting the optimal placement""" 9 | 10 | window = QMainWindow() 11 | button = QPushButton(window) 12 | offsets = { 13 | TooltipPlacement.LEFT: QPoint(0, 0), 14 | TooltipPlacement.RIGHT: QPoint(0, 0), 15 | TooltipPlacement.TOP: QPoint(0, 0), 16 | TooltipPlacement.BOTTOM: QPoint(0, 0) 17 | } 18 | qtbot.addWidget(window) 19 | qtbot.addWidget(button) 20 | 21 | # Left placement 22 | window.setFixedSize(500, 250) 23 | button.move(400, 100) 24 | placement = PlacementUtils.get_optimal_placement(button, QSize(100, 30), 5, offsets) 25 | assert placement == TooltipPlacement.LEFT 26 | 27 | # Right placement 28 | button.move(0, 100) 29 | placement = PlacementUtils.get_optimal_placement(button, QSize(100, 30), 5, offsets) 30 | assert placement == TooltipPlacement.RIGHT 31 | 32 | # Top placement 33 | button.move(250, 250) 34 | placement = PlacementUtils.get_optimal_placement(button, QSize(100, 30), 5, offsets) 35 | assert placement == TooltipPlacement.TOP 36 | 37 | # Bottom placement 38 | button.move(250, 0) 39 | placement = PlacementUtils.get_optimal_placement(button, QSize(100, 30), 5, offsets) 40 | assert placement == TooltipPlacement.BOTTOM 41 | 42 | 43 | def test_get_fallback_placement(qtbot): 44 | """Test getting a fallback placement""" 45 | 46 | window = QMainWindow() 47 | button = QPushButton(window) 48 | offsets = { 49 | TooltipPlacement.LEFT: QPoint(0, 0), 50 | TooltipPlacement.RIGHT: QPoint(0, 0), 51 | TooltipPlacement.TOP: QPoint(0, 0), 52 | TooltipPlacement.BOTTOM: QPoint(0, 0) 53 | } 54 | qtbot.addWidget(window) 55 | qtbot.addWidget(button) 56 | 57 | # Primary placement left -> fallback placement right 58 | button.move(0, 15) 59 | fallback_placement = PlacementUtils.get_fallback_placement( 60 | button, TooltipPlacement.LEFT, 61 | [TooltipPlacement.TOP, TooltipPlacement.RIGHT, TooltipPlacement.BOTTOM], 62 | QSize(50, 20), 5, offsets 63 | ) 64 | assert fallback_placement == TooltipPlacement.RIGHT 65 | 66 | # Primary placement left -> fallback placement bottom 67 | fallback_placement = PlacementUtils.get_fallback_placement( 68 | button, TooltipPlacement.LEFT, 69 | [TooltipPlacement.TOP, TooltipPlacement.BOTTOM], 70 | QSize(50, 20), 5, offsets 71 | ) 72 | assert fallback_placement == TooltipPlacement.BOTTOM 73 | 74 | # Primary placement left -> fallback placement top 75 | button.move(0, 50) 76 | fallback_placement = PlacementUtils.get_fallback_placement( 77 | button, TooltipPlacement.LEFT, 78 | [TooltipPlacement.TOP, TooltipPlacement.RIGHT, TooltipPlacement.BOTTOM], 79 | QSize(50, 20), 5, offsets 80 | ) 81 | assert fallback_placement == TooltipPlacement.TOP 82 | 83 | # Primary placement left -> fallback placement None 84 | button.move(100, 15) 85 | fallback_placement = PlacementUtils.get_fallback_placement( 86 | button, TooltipPlacement.LEFT, 87 | [TooltipPlacement.TOP, TooltipPlacement.RIGHT, TooltipPlacement.BOTTOM], 88 | QSize(50, 20), 5, offsets 89 | ) 90 | assert fallback_placement is None 91 | -------------------------------------------------------------------------------- /tests/tooltip_test.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QMainWindow, QPushButton 2 | from PyQt6.QtCore import QMargins, QPoint, QEasingCurve 3 | from PyQt6.QtGui import QColor, QFont 4 | from src.pyqttooltip import Tooltip, TooltipPlacement 5 | from src.pyqttooltip.constants import DROP_SHADOW_SIZE 6 | 7 | 8 | def test_initial_values(qtbot): 9 | """Test initial values after instantiating""" 10 | 11 | tooltip = Tooltip() 12 | qtbot.addWidget(tooltip) 13 | 14 | assert tooltip.getWidget() is None 15 | assert tooltip.getText() == '' 16 | assert tooltip.getDuration() == 0 17 | assert tooltip.getPlacement() == TooltipPlacement.AUTO 18 | assert tooltip.getFallbackPlacements() == [] 19 | assert tooltip.isTriangleEnabled() == True 20 | assert tooltip.getTriangleSize() == 5 21 | assert tooltip.getOffsetByPlacement(TooltipPlacement.LEFT) == QPoint(0, 0) 22 | assert tooltip.getOffsetByPlacement(TooltipPlacement.RIGHT) == QPoint(0, 0) 23 | assert tooltip.getOffsetByPlacement(TooltipPlacement.TOP) == QPoint(0, 0) 24 | assert tooltip.getOffsetByPlacement(TooltipPlacement.BOTTOM) == QPoint(0, 0) 25 | assert tooltip.getOffsets() == { 26 | TooltipPlacement.LEFT: QPoint(0, 0), 27 | TooltipPlacement.RIGHT: QPoint(0, 0), 28 | TooltipPlacement.TOP: QPoint(0, 0), 29 | TooltipPlacement.BOTTOM: QPoint(0, 0) 30 | } 31 | assert tooltip.getShowDelay() == 50 32 | assert tooltip.getHideDelay() == 50 33 | assert tooltip.getFadeInDuration() == 150 34 | assert tooltip.getFadeOutDuration() == 150 35 | assert tooltip.getFadeInEasingCurve() == QEasingCurve.Type.Linear 36 | assert tooltip.getFadeOutEasingCurve() == QEasingCurve.Type.Linear 37 | assert tooltip.isTextCenteringEnabled() == True 38 | assert tooltip.getBorderRadius() == 2 39 | assert tooltip.isBorderEnabled() == False 40 | assert tooltip.getBackgroundColor() == QColor('#111214') 41 | assert tooltip.getTextColor() == QColor('#CFD2D5') 42 | assert tooltip.getBorderColor() == QColor('#403E41') 43 | assert tooltip.getOpacity() == 1.0 44 | assert tooltip.getFont() == QFont('Arial', 9, QFont.Weight.Bold) 45 | assert tooltip.getMargins() == QMargins(12, 8, 12, 7) 46 | assert tooltip.isDropShadowEnabled() == True 47 | assert tooltip.getDropShadowStrength() == 2.0 48 | assert tooltip.isShowingOnDisabled() == False 49 | 50 | 51 | def test_show_hide(qtbot): 52 | """Test showing and hiding the tooltip""" 53 | 54 | window = QMainWindow() 55 | button = QPushButton(window) 56 | tooltip = Tooltip(button, 'Tooltip') 57 | tooltip.setOpacity(0) 58 | tooltip.setFadeInDuration(0) 59 | tooltip.setShowDelay(0) 60 | qtbot.addWidget(window) 61 | qtbot.addWidget(button) 62 | qtbot.addWidget(tooltip) 63 | 64 | # Show 65 | tooltip.show() 66 | qtbot.wait(250) 67 | assert tooltip.isVisible() == True 68 | 69 | # Hide 70 | tooltip.hide() 71 | qtbot.wait(250) 72 | assert tooltip.isVisible() == False 73 | 74 | 75 | def test_set_widget(qtbot): 76 | """Test setting the widget of the tooltip""" 77 | 78 | button1 = QPushButton() 79 | button2 = QPushButton() 80 | tooltip = Tooltip(button1, 'Tooltip') 81 | qtbot.addWidget(button1) 82 | qtbot.addWidget(button2) 83 | qtbot.addWidget(tooltip) 84 | 85 | tooltip.setWidget(button2) 86 | assert tooltip.getWidget() == button2 87 | 88 | 89 | def test_set_text(qtbot): 90 | """Test setting the text of the tooltip""" 91 | 92 | tooltip = Tooltip(text='Tooltip') 93 | qtbot.addWidget(tooltip) 94 | 95 | tooltip.setText('New tooltip text') 96 | assert tooltip.getText() == 'New tooltip text' 97 | 98 | 99 | def test_set_duration(qtbot): 100 | """Test setting the duration of the tooltip""" 101 | 102 | tooltip = Tooltip(text='Tooltip') 103 | tooltip.setOpacity(0) 104 | tooltip.setFadeInDuration(0) 105 | tooltip.setShowDelay(0) 106 | qtbot.addWidget(tooltip) 107 | 108 | tooltip.setDuration(50) 109 | assert tooltip.getDuration() == 50 110 | 111 | # Tooltip should get hidden 50ms after showing 112 | tooltip.show() 113 | qtbot.wait(500) 114 | assert tooltip.isVisible() == False 115 | 116 | 117 | def test_set_placement(qtbot): 118 | """Test setting the placement of the tooltip""" 119 | 120 | window = QMainWindow() 121 | button = QPushButton(window) 122 | tooltip = Tooltip(button, 'Tooltip') 123 | tooltip.setOpacity(0) 124 | tooltip.setFadeInDuration(0) 125 | tooltip.setShowDelay(0) 126 | tooltip.setDropShadowEnabled(False) 127 | qtbot.addWidget(window) 128 | qtbot.addWidget(button) 129 | qtbot.addWidget(tooltip) 130 | tooltip.show() 131 | qtbot.wait(250) 132 | 133 | # Left 134 | tooltip.setPlacement(TooltipPlacement.LEFT) 135 | assert tooltip.x() == -tooltip.width() 136 | 137 | # Right 138 | tooltip.setPlacement(TooltipPlacement.RIGHT) 139 | assert tooltip.x() == button.width() 140 | 141 | # Top 142 | tooltip.setPlacement(TooltipPlacement.TOP) 143 | assert tooltip.y() == -tooltip.height() 144 | 145 | # Bottom 146 | tooltip.setPlacement(TooltipPlacement.BOTTOM) 147 | assert tooltip.y() == button.height() 148 | 149 | 150 | def test_set_fallback_placements(qtbot): 151 | """Test setting the fallback placements of the tooltip""" 152 | 153 | window = QMainWindow() 154 | button = QPushButton(window) 155 | tooltip = Tooltip(button, 'Tooltip') 156 | tooltip.setPlacement(TooltipPlacement.LEFT) 157 | qtbot.addWidget(window) 158 | qtbot.addWidget(button) 159 | qtbot.addWidget(tooltip) 160 | 161 | fallback_placements = [TooltipPlacement.BOTTOM, TooltipPlacement.RIGHT] 162 | tooltip.setFallbackPlacements(fallback_placements) 163 | assert tooltip.getFallbackPlacements() == fallback_placements 164 | assert tooltip.getActualPlacement() == TooltipPlacement.BOTTOM 165 | 166 | 167 | def test_set_triangle(qtbot): 168 | """Test enabling and disabling triangle and changing its size""" 169 | 170 | window = QMainWindow() 171 | button = QPushButton(window) 172 | tooltip = Tooltip(button, 'Tooltip') 173 | tooltip.setPlacement(TooltipPlacement.RIGHT) 174 | tooltip.setOpacity(0) 175 | tooltip.setFadeInDuration(0) 176 | tooltip.setShowDelay(0) 177 | qtbot.addWidget(window) 178 | qtbot.addWidget(button) 179 | qtbot.addWidget(tooltip) 180 | width = tooltip.width() 181 | 182 | # Bigger triangle 183 | tooltip.setTriangleSize(7) 184 | assert tooltip.width() == width + 2 185 | 186 | # Disabled triangle 187 | tooltip.setTriangleEnabled(False) 188 | assert tooltip.width() == width - 5 189 | 190 | 191 | def test_set_offsets(qtbot): 192 | """Test setting the offsets of the tooltip""" 193 | 194 | offsets = { 195 | TooltipPlacement.LEFT: QPoint(-10, 0), 196 | TooltipPlacement.RIGHT: QPoint(10, 0), 197 | TooltipPlacement.TOP: QPoint(0, -10), 198 | TooltipPlacement.BOTTOM: QPoint(0, 10) 199 | } 200 | 201 | window = QMainWindow() 202 | button = QPushButton(window) 203 | tooltip = Tooltip(button, 'Tooltip') 204 | tooltip.setOffsets(offsets) 205 | tooltip.setOpacity(0) 206 | tooltip.setFadeInDuration(0) 207 | tooltip.setShowDelay(0) 208 | tooltip.setDropShadowEnabled(False) 209 | qtbot.addWidget(window) 210 | qtbot.addWidget(button) 211 | qtbot.addWidget(tooltip) 212 | 213 | # Left 214 | tooltip.setPlacement(TooltipPlacement.LEFT) 215 | assert tooltip.x() == -tooltip.width() - 10 216 | 217 | # Right 218 | tooltip.setPlacement(TooltipPlacement.RIGHT) 219 | assert tooltip.x() == button.width() + 10 220 | 221 | # Top 222 | tooltip.setPlacement(TooltipPlacement.TOP) 223 | assert tooltip.y() == -tooltip.height() - 10 224 | 225 | # Bottom 226 | tooltip.setPlacement(TooltipPlacement.BOTTOM) 227 | assert tooltip.y() == button.height() + 10 228 | 229 | 230 | def test_set_delays(qtbot): 231 | """Test setting the delays""" 232 | 233 | tooltip = Tooltip(text='Tooltip') 234 | qtbot.addWidget(tooltip) 235 | 236 | tooltip.setShowDelay(100) 237 | tooltip.setHideDelay(80) 238 | assert tooltip.getShowDelay() == 100 239 | assert tooltip.getHideDelay() == 80 240 | 241 | 242 | def test_set_fade_durations(qtbot): 243 | """Test setting the fade in / out animation durations""" 244 | 245 | tooltip = Tooltip(text='Tooltip') 246 | qtbot.addWidget(tooltip) 247 | 248 | tooltip.setFadeInDuration(250) 249 | tooltip.setFadeOutDuration(220) 250 | assert tooltip.getFadeInDuration() == 250 251 | assert tooltip.getFadeOutDuration() == 220 252 | 253 | 254 | def test_set_easing_curves(qtbot): 255 | """Test setting the easing curves for the fade animations""" 256 | 257 | tooltip = Tooltip(text='Tooltip') 258 | qtbot.addWidget(tooltip) 259 | 260 | tooltip.setFadeInEasingCurve(QEasingCurve.Type.OutCurve) 261 | tooltip.setFadeOutEasingCurve(QEasingCurve.Type.InCubic) 262 | assert tooltip.getFadeInEasingCurve() == QEasingCurve.Type.OutCurve 263 | assert tooltip.getFadeOutEasingCurve() == QEasingCurve.Type.InCubic 264 | 265 | tooltip.setFadeInEasingCurve(None) 266 | tooltip.setFadeOutEasingCurve(None) 267 | assert tooltip.getFadeInEasingCurve() == QEasingCurve.Type.Linear 268 | assert tooltip.getFadeOutEasingCurve() == QEasingCurve.Type.Linear 269 | 270 | 271 | def test_set_text_centering_enabled(qtbot): 272 | """Test disabling text centering for wrapped text""" 273 | 274 | tooltip = Tooltip(text='Tooltip') 275 | qtbot.addWidget(tooltip) 276 | 277 | tooltip.setTextCenteringEnabled(False) 278 | assert tooltip.isTextCenteringEnabled() == False 279 | 280 | 281 | def test_set_border_radius(qtbot): 282 | """Test setting the border radius""" 283 | 284 | tooltip = Tooltip(text='Tooltip') 285 | qtbot.addWidget(tooltip) 286 | 287 | tooltip.setBorderRadius(4) 288 | assert tooltip.getBorderRadius() == 4 289 | 290 | 291 | def test_set_border_enabled(qtbot): 292 | """Test enabling the border""" 293 | 294 | tooltip = Tooltip(text='Tooltip') 295 | qtbot.addWidget(tooltip) 296 | 297 | tooltip.setBorderEnabled(True) 298 | assert tooltip.isBorderEnabled() == True 299 | 300 | 301 | def test_set_colors(qtbot): 302 | """Test setting the colors of the tooltip""" 303 | 304 | tooltip = Tooltip(text='Tooltip') 305 | qtbot.addWidget(tooltip) 306 | 307 | tooltip.setBackgroundColor(QColor('#FFFFFF')) 308 | tooltip.setTextColor(QColor('#000000')) 309 | tooltip.setBorderColor(QColor('#DEDEDE')) 310 | assert tooltip.getBackgroundColor() == QColor('#FFFFFF') 311 | assert tooltip.getTextColor() == QColor('#000000') 312 | assert tooltip.getBorderColor() == QColor('#DEDEDE') 313 | 314 | 315 | def test_set_font(qtbot): 316 | """Test setting the font of the tooltip""" 317 | 318 | tooltip = Tooltip(text='Tooltip') 319 | qtbot.addWidget(tooltip) 320 | 321 | font = QFont('Arial', 9, QFont.Weight.Medium) 322 | tooltip.setFont(font) 323 | assert tooltip.getFont() == font 324 | assert tooltip.font() == font 325 | 326 | 327 | def test_set_margins(qtbot): 328 | """Test setting the margins of the tooltip content""" 329 | 330 | window = QMainWindow() 331 | button = QPushButton(window) 332 | tooltip = Tooltip(button, 'Tooltip') 333 | tooltip.setMargins(QMargins(0, 0, 0, 0)) 334 | tooltip.setOpacity(0) 335 | tooltip.setFadeInDuration(0) 336 | tooltip.setShowDelay(0) 337 | qtbot.addWidget(window) 338 | qtbot.addWidget(button) 339 | qtbot.addWidget(tooltip) 340 | size = tooltip.size() 341 | 342 | tooltip.setMargins(QMargins(10, 5, 10, 5)) 343 | assert tooltip.width() == size.width() + 20 344 | assert tooltip.height() == size.height() + 10 345 | 346 | 347 | def test_set_drop_shadow_enabled(qtbot): 348 | """Test disabling the drop shadow""" 349 | 350 | window = QMainWindow() 351 | button = QPushButton(window) 352 | tooltip = Tooltip(button, 'Tooltip') 353 | tooltip.setPlacement(TooltipPlacement.TOP) 354 | tooltip.setOpacity(0) 355 | tooltip.setFadeInDuration(0) 356 | tooltip.setShowDelay(0) 357 | qtbot.addWidget(window) 358 | qtbot.addWidget(button) 359 | qtbot.addWidget(tooltip) 360 | tooltip.show() 361 | qtbot.wait(250) 362 | size = tooltip.size() 363 | 364 | tooltip.setDropShadowEnabled(False) 365 | assert tooltip.width() == size.width() - DROP_SHADOW_SIZE * 2 366 | assert tooltip.height() == size.height() - DROP_SHADOW_SIZE * 2 + tooltip.getTriangleSize() 367 | 368 | 369 | def test_set_drop_shadow_strength(qtbot): 370 | """Test setting the strength of the drop shadow""" 371 | 372 | tooltip = Tooltip(text='Tooltip') 373 | qtbot.addWidget(tooltip) 374 | 375 | tooltip.setDropShadowStrength(3.5) 376 | assert tooltip.getDropShadowStrength() == 3.5 377 | 378 | 379 | def test_set_maximum_width(qtbot): 380 | """Test setting a maximum width for the tooltip""" 381 | 382 | window = QMainWindow() 383 | button = QPushButton(window) 384 | tooltip = Tooltip(button, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr') 385 | tooltip.setMaximumWidth(150) 386 | tooltip.setOpacity(0) 387 | tooltip.setFadeInDuration(0) 388 | tooltip.setShowDelay(0) 389 | tooltip.setDropShadowEnabled(False) 390 | qtbot.addWidget(window) 391 | qtbot.addWidget(button) 392 | qtbot.addWidget(tooltip) 393 | 394 | assert tooltip.maximumWidth() == 150 395 | assert tooltip.width() <= 150 396 | 397 | 398 | def test_change_top_level_parent(qtbot): 399 | """Test changing the top level parent of the widget""" 400 | 401 | window1 = QMainWindow() 402 | window2 = QMainWindow() 403 | window2.move(100, 150) 404 | button = QPushButton(window1) 405 | tooltip = Tooltip(button, 'Tooltip') 406 | tooltip.setPlacement(TooltipPlacement.TOP) 407 | tooltip.setOpacity(0) 408 | tooltip.setFadeInDuration(0) 409 | tooltip.setShowDelay(0) 410 | tooltip.setDropShadowEnabled(False) 411 | qtbot.addWidget(window1) 412 | qtbot.addWidget(window2) 413 | qtbot.addWidget(button) 414 | qtbot.addWidget(tooltip) 415 | x = tooltip.x() 416 | y = tooltip.y() 417 | 418 | # Change button parent and check if tooltip changes position 419 | button.setParent(window2) 420 | tooltip.update() 421 | assert tooltip.x() == x + 100 422 | assert tooltip.y() == y + 150 423 | 424 | 425 | def test_delete_parent(qtbot): 426 | """Test deleting the parent of the widget while the tooltip is showing""" 427 | 428 | window = QMainWindow() 429 | button = QPushButton(window) 430 | tooltip = Tooltip(button, 'Tooltip') 431 | tooltip.setPlacement(TooltipPlacement.TOP) 432 | tooltip.setOpacity(0) 433 | tooltip.setFadeInDuration(0) 434 | tooltip.setShowDelay(0) 435 | tooltip.setDropShadowEnabled(False) 436 | qtbot.addWidget(tooltip) 437 | tooltip.show() 438 | qtbot.wait(250) 439 | 440 | # Delete window and check if tooltip is now hidden 441 | assert tooltip.isVisible() == True 442 | window.deleteLater() 443 | qtbot.wait(250) 444 | assert tooltip.isVisible() == False 445 | 446 | 447 | def test_move_top_level_parent(qtbot): 448 | """Test moving the top level parent of the widget""" 449 | 450 | window = QMainWindow() 451 | button = QPushButton(window) 452 | tooltip = Tooltip(button, 'Tooltip') 453 | tooltip.setPlacement(TooltipPlacement.TOP) 454 | tooltip.setOpacity(0) 455 | tooltip.setFadeInDuration(0) 456 | tooltip.setShowDelay(0) 457 | tooltip.setDropShadowEnabled(False) 458 | qtbot.addWidget(window) 459 | qtbot.addWidget(button) 460 | qtbot.addWidget(tooltip) 461 | x = tooltip.x() 462 | y = tooltip.y() 463 | 464 | # Move window and check if tooltip follows its position 465 | window.move(100, 50) 466 | tooltip.update() 467 | assert tooltip.x() == x + 100 468 | assert tooltip.y() == y + 50 469 | 470 | 471 | def test_resize_widget(qtbot): 472 | """Test resizing the widget of the tooltip""" 473 | 474 | window = QMainWindow() 475 | button = QPushButton(window) 476 | button.resize(100, 25) 477 | tooltip = Tooltip(button, 'Tooltip') 478 | tooltip.setPlacement(TooltipPlacement.BOTTOM) 479 | tooltip.setOpacity(0) 480 | tooltip.setFadeInDuration(0) 481 | tooltip.setShowDelay(0) 482 | tooltip.setDropShadowEnabled(False) 483 | qtbot.addWidget(window) 484 | qtbot.addWidget(button) 485 | qtbot.addWidget(tooltip) 486 | y = tooltip.y() 487 | 488 | # Change button height by 25px and check if y position changed by 25px 489 | button.resize(100, 50) 490 | tooltip.update() 491 | assert tooltip.y() == y + 25 492 | 493 | 494 | def test_move_widget(qtbot): 495 | """Test moving the widget of the tooltip""" 496 | 497 | window = QMainWindow() 498 | button = QPushButton(window) 499 | tooltip = Tooltip(button, 'Tooltip') 500 | tooltip.setPlacement(TooltipPlacement.BOTTOM) 501 | tooltip.setOpacity(0) 502 | tooltip.setFadeInDuration(0) 503 | tooltip.setShowDelay(0) 504 | tooltip.setDropShadowEnabled(False) 505 | qtbot.addWidget(window) 506 | qtbot.addWidget(button) 507 | qtbot.addWidget(tooltip) 508 | x = tooltip.x() 509 | y = tooltip.y() 510 | 511 | # Move button and check if tooltip follows its position 512 | button.move(100, 50) 513 | tooltip.update() 514 | assert tooltip.x() == x + 100 515 | assert tooltip.y() == y + 50 516 | -------------------------------------------------------------------------------- /tests/utils_test.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QMainWindow, QWidget, QPushButton 2 | from src.pyqttooltip.utils import Utils 3 | 4 | 5 | def test_get_top_level_parent(qtbot): 6 | """Test getting the top level parent of a widget""" 7 | 8 | window = QMainWindow() 9 | widget = QWidget(window) 10 | button1 = QPushButton(widget) 11 | button2 = QPushButton() 12 | qtbot.addWidget(window) 13 | qtbot.addWidget(widget) 14 | qtbot.addWidget(button1) 15 | qtbot.addWidget(button2) 16 | 17 | assert Utils.get_top_level_parent(button1) == window 18 | assert Utils.get_top_level_parent(button2) == button2 19 | 20 | 21 | def test_get_parents(qtbot): 22 | """Test getting all the parents of a widget""" 23 | 24 | window = QMainWindow() 25 | widget = QWidget(window) 26 | button1 = QPushButton(widget) 27 | button2 = QPushButton() 28 | qtbot.addWidget(window) 29 | qtbot.addWidget(widget) 30 | qtbot.addWidget(button1) 31 | qtbot.addWidget(button2) 32 | 33 | assert Utils.get_parents(button1) == [widget, window] 34 | assert Utils.get_parents(button2) == [] 35 | --------------------------------------------------------------------------------