As a part of bigger project, I want to add tutorial mode after first user login. That mode is simply a QFrame
showing description of highlighted element in parent window + Next
/Cancel
buttons, moving to the next element or stopping tutorial mode completely.
In particular, it works as follows: enter image description here
Main Window
is only for illustration purposes, need review only for Tutorial Manager
and Hint
.
Code is written in Python 3.11.7
, lib version - Pyside 6.7.2
.
import sys
from PySide6.QtCore import Qt, QTimer, QRect, QPoint
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import (QApplication, QMainWindow, QLabel, QPushButton,
QLineEdit, QVBoxLayout, QWidget, QHBoxLayout,
QFrame)
def load_stylesheet(filename):
with open(filename, 'r') as f:
return f.read()
class TutorialHint(QFrame):
def __init__(self, text, parent=None):
super().__init__(parent)
self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setStyleSheet(load_stylesheet('tutorial_hint.qss'))
layout = QVBoxLayout(self)
hint_text = QLabel(text)
hint_text.setWordWrap(True)
layout.addWidget(hint_text)
button_layout = QHBoxLayout()
self.next_button = QPushButton("Next")
self.stop_button = QPushButton("Stop")
button_layout.addWidget(self.next_button)
button_layout.addWidget(self.stop_button)
layout.addLayout(button_layout)
self.target_element = None
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.setBrush(QColor(224, 224, 224))
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(self.rect(), 10, 10)
def set_target_element(self, element):
self.target_element = element
self.update_position()
def update_position(self):
if self.target_element and self.parent():
element_rect = self.target_element.geometry()
element_global_rect = QRect(self.parent().mapToGlobal(element_rect.topLeft()), element_rect.size())
hint_pos = element_global_rect.topRight() + QPoint(20, 0)
screen_rect = self.screen().geometry()
if hint_pos.x() + self.width() > screen_rect.right():
hint_pos.setX(screen_rect.right() - self.width())
if hint_pos.y() + self.height() > screen_rect.bottom():
hint_pos.setY(screen_rect.bottom() - self.height())
self.move(hint_pos)
def moveEvent(self, event):
super().moveEvent(event)
self.update_position()
class TutorialManager:
def __init__(self, parent, tutorial_steps):
self.parent = parent
self.tutorial_steps = tutorial_steps
self.current_step = 0
self.current_hint = None
self.highlight_style = load_stylesheet('highlight.qss')
self.original_stylesheet = self.parent.styleSheet()
def start_tutorial(self):
QTimer.singleShot(500, self.show_tutorial_step)
def show_tutorial_step(self):
if self.current_step < len(self.tutorial_steps):
element, text = self.tutorial_steps[self.current_step]
# Remove highlight from previous element
if self.current_step > 0:
prev_element = self.tutorial_steps[self.current_step - 1][0]
prev_element.setGraphicsEffect(None)
prev_element.setStyleSheet("")
# Apply highlight effect and style
element.setStyleSheet(self.highlight_style)
# Show hint dialog
if self.current_hint:
self.current_hint.close()
self.current_hint = TutorialHint(text, self.parent)
self.current_hint.next_button.clicked.connect(self.next_tutorial_step)
self.current_hint.stop_button.clicked.connect(self.end_tutorial)
self.current_hint.set_target_element(element)
self.current_hint.show()
def next_tutorial_step(self):
self.current_step += 1
if self.current_step < len(self.tutorial_steps):
self.show_tutorial_step()
else:
self.end_tutorial()
def end_tutorial(self):
for element, _ in self.tutorial_steps:
element.setStyleSheet("")
if self.current_hint:
self.current_hint.close()
self.current_hint = None
self.current_step = 0
self.parent.highlighted_element = None
self.parent.setStyleSheet(self.original_stylesheet)
self.parent.update()
def update_hint_position(self):
if self.current_hint:
self.current_hint.update_position()
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("PySide6 Example with Tutorial")
self.setGeometry(100, 100, 500, 300)
self.setStyleSheet(load_stylesheet('start_style.qss'))
central_widget = QWidget()
main_layout = QHBoxLayout(central_widget)
# Left VBox
left_vbox = QVBoxLayout()
self.label1 = QLabel("Left Label")
self.text_box1 = QLineEdit()
self.text_box1.setPlaceholderText("Enter text for left label...")
self.button1 = QPushButton("Update Left Label")
self.button1.clicked.connect(self.update_left_label)
left_vbox.addWidget(self.label1)
left_vbox.addWidget(self.text_box1)
left_vbox.addWidget(self.button1)
left_vbox.addStretch()
# Right VBox
right_vbox = QVBoxLayout()
self.label2 = QLabel("Right Label")
self.text_box2 = QLineEdit()
self.text_box2.setPlaceholderText("Enter text for right label...")
self.button2 = QPushButton("Update Right Label")
self.button2.clicked.connect(self.update_right_label)
right_vbox.addWidget(self.label2)
right_vbox.addWidget(self.text_box2)
right_vbox.addWidget(self.button2)
right_vbox.addStretch()
main_layout.addLayout(left_vbox)
main_layout.addLayout(right_vbox)
self.setCentralWidget(central_widget)
tutorial_steps = [
(self.label1, "This is the left label that displays text."),
(self.text_box1, "Enter text here to update the left label."),
(self.button1, "Click this button to update the left label."),
(self.label2, "This is the right label that displays text."),
(self.text_box2, "Enter text here to update the right label."),
(self.button2, "Click this button to update the right label.")
]
self.tutorial_manager = TutorialManager(self, tutorial_steps)
self.tutorial_manager.start_tutorial()
def update_left_label(self):
self.label1.setText(f"Left: {self.text_box1.text()}")
def update_right_label(self):
self.label2.setText(f"Right: {self.text_box2.text()}")
def moveEvent(self, event):
super().moveEvent(event)
self.tutorial_manager.update_hint_position()
def resizeEvent(self, event):
super().resizeEvent(event)
self.tutorial_manager.update_hint_position()
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
.qss
styles:
/* start_style.qss */
QMainWindow {
background-color: #f0f0f0;
}
QLabel {
font-size: 14px;
color: #333333;
}
QLineEdit {
padding: 5px;
border: 1px solid #cccccc;
border-radius: 4px;
background-color: white;
font-size: 13px;
}
QPushButton {
background-color: #4CAF50;
color: white;
padding: 6px 12px;
border: none;
border-radius: 4px;
font-size: 13px;
}
QPushButton:hover {
background-color: #45a049;
}
QPushButton:pressed {
background-color: #3d8b40;
}
TutorialHint {
background-color: #E0E0E0; /* Light gray */
border: 1px solid #BDBDBD; /* Medium gray border */
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Subtle shadow */
box-sizing: border-box; /* Include border in element's dimensions */
}
TutorialHint QLabel {
color: #333333; /* Dark gray text */
font-size: 14px;
padding: 10px;
}
TutorialHint QPushButton {
background-color: #9E9E9E; /* Medium gray */
color: #FFFFFF; /* White text */
border: none;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
}
TutorialHint QPushButton:hover {
background-color: #757575; /* Darker gray on hover */
}
TutorialHint QPushButton:pressed {
background-color: #616161; /* Even darker when pressed */
}
/* highlight.qss */
* {
background-color: #FFFACD; /* Light yellow */
border: 1px solid #FF4500; /* OrangeRed */
color: #8B0000; /* Dark red */
}
Example results:
How can I improve my code for TutorialManager
/Hint
?
The one thing that actually bothers me, is that Hint
frame may be shown out of bounds of parent window (see Example #2). On the other hand, I will need to handle all such cases + do something in case if there's not enough space in parent window to contain Hint
frame in full.
1 Answer 1
To handle the out of bounds scenarios that you're worried about, you can adjust the position calculation logic to ensure the hint will stay within the visible area of the parent window. Here’s a quick version of your update_position
method in the TutorialHint
class:
def update_position(self):
if self.target_element and self.parent():
element_rect = self.target_element.geometry()
element_global_rect = QRect(self.parent().mapToGlobal(element_rect.topLeft()), element_rect.size())
hint_pos = element_global_rect.topRight() + QPoint(20, 0)
# Use parent window's geometry
screen_rect = self.parent().geometry()
# Adjust position to keep the hint inside the parent window
if hint_pos.x() + self.width() > screen_rect.right():
# Add margin to prevent overlapping edge
hint_pos.setX(screen_rect.right() - self.width() - 20)
if hint_pos.y() + self.height() > screen_rect.bottom():
# Adding margin to prevent overlapping edge
hint_pos.setY(screen_rect.bottom() - self.height() - 20)
if hint_pos.x() < screen_rect.left():
# Adding margin to prevent overlapping edge
hint_pos.setX(screen_rect.left() + 20)
if hint_pos.y() < screen_rect.top():
# Adding margin to prevent overlapping edge
hint_pos.setY(screen_rect.top() + 20)
self.move(hint_pos)
Now, if the parent window does not have enough space to display the hint in the desired position, you can probably add a fallback mechanism to position the hint either on the other side of the element or in a different position where it fits better. Something like this:
def update_position(self):
if self.target_element and self.parent():
element_rect = self.target_element.geometry()
element_global_rect = QRect(self.parent().mapToGlobal(element_rect.topLeft()), element_rect.size())
hint_pos = element_global_rect.topRight() + QPoint(20, 0)
screen_rect = self.parent().geometry() # Use parent window's geometry
if hint_pos.x() + self.width() > screen_rect.right():
hint_pos = element_global_rect.topLeft() - QPoint(self.width() + 20, 0)
if hint_pos.y() + self.height() > screen_rect.bottom():
hint_pos = element_global_rect.bottomRight() - QPoint(self.width(), self.height() + 20)
if hint_pos.x() < screen_rect.left():
hint_pos.setX(screen_rect.left() + 20)
if hint_pos.y() < screen_rect.top():
hint_pos.setY(screen_rect.top() + 20)
self.move(hint_pos)
There is, however, a chance that if the parent window is small, and there's absolutely no room to display the hint, you might need to implement scrolling or resizing of the hint window itself to fit within the available space. Keep this in mind.
More, instead of using QSS for highlighting, consider using QGraphicsEffect
, which provides more flexibility and can be combined with animations to create a smoother user experience:
from PySide6.QtWidgets import QGraphicsColorizeEffect
def apply_highlight_effect(self, element):
effect = QGraphicsColorizeEffect()
effect.setColor(QColor("#FF4500")) # Highlight color
element.setGraphicsEffect(effect)