├── .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 | [](https://pypi.org/project/pyqttooltip/)
4 | [](https://github.com/niklashenning/pyqttooltip)
5 | [](https://github.com/niklashenning/pyqttooltip)
6 | [](https://github.com/niklashenning/pyqttooltip)
7 | [](https://github.com/niklashenning/pyqttooltip/blob/master/LICENSE)
8 |
9 |
10 | A modern and fully customizable tooltip library for PyQt and PySide
11 |
12 | 
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 |
--------------------------------------------------------------------------------