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.

\$\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)
\$\endgroup\$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.