Thursday, September 19, 2024

Building a Custom Scripting Language in PyQt6 for Dynamic Widget Rendering

 In this tutorial, we’ll walk through creating a custom scripting language interpreter that renders widgets dynamically in a PyQt6-based application. This approach simulates a simple browser-like software where widgets (such as buttons, labels, and text inputs) are rendered based on commands from a text script.

The rationale behind this project stems from a common challenge faced when distributing software as executable (EXE) files: updating features. Typically, software developers require users or customers to download the latest EXE files to get access to new features or updates. This approach can be inconvenient and time-consuming for users, who are forced to repeatedly download and install new versions of the software.

To address this issue, our solution involves distributing a generic script interpreter instead of a static EXE file. With this setup, the core interpreter remains the same, while the functionality and features can be dynamically updated through script files. This allows for seamless updates, as users only need to download the updated script without modifying the base software. Furthermore, users are given the flexibility to customize the software to suit their specific needs by editing or adding to the script.

This method not only enhances the user experience by simplifying updates, but it also fosters a more adaptable and customizable software environment, empowering users to tailor the solution to their unique requirements.

We will explore how to:

  • Create a PyQt6 application with a text editor and preview pane.
  • Design a simple scripting language syntax.
  • Interpret the script and render the widgets dynamically.
  • This is the expected output:

The Custom Script Syntax

Our custom scripting language will be simple and focus on three types of widgets:

  1. Labels: Display a static text label.
  2. TextInput: Create a text input field.
  3. Button: Add a clickable button.

Additionally, we will support:

  • Horizontal Layouts: Group widgets horizontally for better control over their layout.

Here is a basic example of the script syntax:

1
2
3
4
5
6
7
8
9
Label Enter your name:
TextInput Your name
Button Submit

Horizontal:
    Label Username:
    TextInput Enter your username
    Button Login
End Horizontal

The syntax is designed to be readable:

  • Commands are prefixed with the widget type (e.g., Label, TextInput, or Button).
  • The content for each widget (such as label text or input placeholder) follows the command.
  • Horizontal layout starts with the Horizontal: command and ends with End Horizontal.

PyQt6 Application Overview

The application consists of two main sections:

  1. Left Pane: A QTextEdit where users can input the script, and a button to execute the script.
  2. Right Pane: A QScrollArea to display the rendered widgets based on the script.

Here’s how the application layout looks:

  • The left pane allows you to write and edit your script.
  • The right pane shows the dynamically generated interface as defined by the script.

The Code

Let's dive into the full code:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import sys
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QSplitter, QTextEdit, QPushButton, 
    QVBoxLayout, QHBoxLayout, QWidget, QScrollArea, QLabel, QLineEdit, 
    QSpacerItem, QSizePolicy
)
from PyQt6.QtCore import Qt

class ScriptInterpreter:
    def __init__(self, script, preview_container):
        self.script = script
        self.preview_container = preview_container

    def interpret(self):
        # Clear previous widgets
        layout = self.preview_container.layout()
        if layout:
            for i in reversed(range(layout.count())):
                widget_to_remove = layout.itemAt(i).widget()
                if widget_to_remove is not None:
                    widget_to_remove.setParent(None)

        end_horizontal_found = True
        
        # Parse the script line by line
        lines = self.script.splitlines()
        for line in lines:
            line = line.strip()
            
            # Render Button if not inside a horizontal layout
            if line.startswith("Button") and end_horizontal_found == True:
                self._create_button(line)
                
            # Start horizontal layout
            elif line.startswith("Horizontal:"):
                horizontal_layout = QHBoxLayout()
                horizontal_layout.setSpacing(10)
                self._add_hbox_layout_to_container(horizontal_layout)
                end_horizontal_found = False
            
            # Render Button inside horizontal layout
            elif line.startswith("Button") and end_horizontal_found == False:
                self._create_button(line, horizontal_layout)
                
            # Render Label inside horizontal layout
            elif line.startswith("Label") and end_horizontal_found == False:
                self._create_label(line, horizontal_layout)
                
            # Render TextInput inside horizontal layout
            elif line.startswith("TextInput") and end_horizontal_found == False:
                self._create_text_input(line, horizontal_layout)
                
            # Render Label outside horizontal layout
            elif line.startswith("Label") and end_horizontal_found == True:
                self._create_label(line)
                
            # Render TextInput outside horizontal layout
            elif line.startswith("TextInput") and end_horizontal_found == True:
                self._create_text_input(line)
                
            # End horizontal layout
            elif line.startswith("End Horizontal"):
                end_horizontal_found = True

        # Add spacer to push everything to the top
        spacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
        layout.addItem(spacer)

    def _create_button(self, line, layout=None):
        _, label = line.split(maxsplit=1)
        button = QPushButton(label)
        button.setFixedHeight(40)  # Set fixed height
        if layout:
            layout.addWidget(button)
        else:
            self.preview_container.layout().addWidget(button)

    def _create_label(self, line, layout=None):
        _, text = line.split(maxsplit=1)
        label = QLabel(text)
        label.setFixedHeight(40)  # Set fixed height
        if layout:
            layout.addWidget(label)
        else:
            self.preview_container.layout().addWidget(label)

    def _create_text_input(self, line, layout=None):
        _, placeholder = line.split(maxsplit=1)
        text_input = QLineEdit()
        text_input.setPlaceholderText(placeholder)
        text_input.setFixedHeight(40)  # Set fixed height
        if layout:
            layout.addWidget(text_input)
        else:
            self.preview_container.layout().addWidget(text_input)

    def _add_hbox_layout_to_container(self, layout):
        hbox_widget = QWidget()
        hbox_widget.setLayout(layout)
        self.preview_container.layout().addWidget(hbox_widget)


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Custom Scripting Language Interpreter")
        self.setGeometry(100, 100, 800, 600)

        # Create a QSplitter to split left and right sections
        self.splitter = QSplitter(Qt.Orientation.Horizontal, self)

        # Left Pane: TextEdit for entering script and a button to test it
        self.left_widget = QWidget()
        self.left_layout = QVBoxLayout(self.left_widget)

        self.text_edit = QTextEdit()
        self.text_edit.setPlaceholderText("Enter your script here...")

        self.test_button = QPushButton("Test Script")
        self.test_button.clicked.connect(self.run_script)

        self.left_layout.addWidget(self.text_edit)
        self.left_layout.addWidget(self.test_button)

        # Right Pane: ScrollArea to preview the widgets from the script
        self.scroll_area = QScrollArea()
        self.scroll_area.setWidgetResizable(True)

        self.preview_container = QWidget()
        self.preview_container.setLayout(QVBoxLayout())
        self.scroll_area.setWidget(self.preview_container)

        # Add left and right panes to the splitter
        self.splitter.addWidget(self.left_widget)
        self.splitter.addWidget(self.scroll_area)
        splitter_sizes = [int(0.30 * self.width()), int(0.70 * self.width())]
        self.splitter.setSizes(splitter_sizes)

        # Set splitter as the central widget of the main window
        self.setCentralWidget(self.splitter)

    def run_script(self):
        # Get the script from the text editor
        script = self.text_edit.toPlainText()
        print("Script to be interpreted:")
        print(script)

        # Interpret and render the widgets in the right pane
        interpreter = ScriptInterpreter(script, self.preview_container)
        interpreter.interpret()


# Main application
app = QApplication(sys.argv)
main_window = MainWindow()
main_window.show()

# Run the PyQt6 event loop
sys.exit(app.exec())

Script Syntax Breakdown

  1. Button: Adds a button to the window.
    • Example: Button Submit
  2. Label: Adds a label with the specified text.
    • Example: Label Enter your name:
  3. TextInput: Adds a text input field with a placeholder.
    • Example: TextInput Your name
  4. Horizontal: Groups widgets in a horizontal layout, terminated by End Horizontal.
    • Example:

1
2
3
4
5
Horizontal:
    Label Username:
    TextInput Enter your username
    Button Login
End Horizontal

Methodology

The overall methodology used in this program is based on dynamic user interface generation:

  1. Script-Driven UI Creation: Users define the layout and content of the user interface through a custom script, which is then interpreted and translated into PyQt6 widgets. This separates the UI logic from the rendering logic.

  2. Component-Based Approach: Each widget type (Button, Label, TextInput) is encapsulated in separate methods for modularity. This makes the code easy to extend—additional widgets or layouts can be added by simply adding new methods in the ScriptInterpreter class.

  3. Event-Driven Execution: The script is executed when the user presses the “Test Script” button. PyQt6's event loop then dynamically updates the preview pane.

Conclusion

This project demonstrates how to build a custom scripting language interpreter in PyQt6 to render widgets dynamically. By separating the script definition from the rendering logic, this approach allows users to easily define complex layouts with simple text-based commands.

Potential Extensions

  • More Widget Types: You could extend the script to support additional PyQt6 widgets such as dropdowns, checkboxes, or sliders.
  • Advanced Layouts: Implement vertical layouts, grids, or even nested layouts to give users more control over the UI.
  • Error Handling: Add robust error handling and feedback to help users write correct scripts.

No comments:

Post a Comment