4
\$\begingroup\$

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:

enter image description here

enter image description here

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.

asked Aug 24, 2024 at 23:40
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

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)
answered Aug 27, 2024 at 14:08
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.