Friday, September 20, 2024

Enhancing the Custom Scripting Language Interpreter in Python with PyQt6

 In this blog post, we’ll dive into the enhancements made to the custom scripting language and its interpreter built using Python and PyQt6. If you have not read it yet, click here(Building a Custom Scripting Language in PyQt6 for Dynamic Widget Rendering) This project allows users to dynamically generate UI components based on a simple, user-friendly script. The focus is on key improvements such as the command execution, clearing the right pane before displaying new widgets, saving widgets into a registry for better control, and additional functionality like widget alignment and action handling.

Let's walk through the enhancements and improvements in this project:

Key Enhancements

1. Clearing the Right Pane Before Displaying New Widgets

Before the interpreter renders new widgets based on the script, we ensure the right pane is cleared. This prevents residual widgets from previous script executions from cluttering the view.

1
2
3
4
5
6
# Remove all widgets from the layout
while layout.count():
    item = layout.takeAt(0)  # Get the first item from the layout
    widget = item.widget()   # Get the widget from the layout item
    if widget is not None:
        widget.setParent(None)  # Remove the widget from the layout

This block of code ensures that the right pane starts fresh every time a new script is run, improving the user experience and maintaining a clean interface.

2. Action Handling When Buttons Are Clicked

One of the most significant enhancements is adding the ability to define actions within a script. This is accomplished through the use of Command and End Command blocks. The actions are linked to widgets, primarily buttons, allowing for dynamic behavior based on user interaction.

1
2
3
4
# Command for button click
Command:
    Button_Clicked:Submit:print Text1:Label2
End Command

The above block of code defines that when the "Submit" button is clicked, it prints the content of Text1 and updates the text in Label2. This flexibility opens up many possibilities, such as form handling, real-time updates, and interactive elements in the generated UI.

3. Saving All Widgets in a Widget Registry

To maintain control over the dynamically created widgets, we store them in a registry. This allows for easy access, modification, and action application based on widget names provided in the script.

1
self.widget_registry = {}  # Store references to widgets by name

Each widget (e.g., buttons, labels, text inputs) is stored in this registry when created. This makes it easy to retrieve them later when executing commands like updating text or handling button clicks.

For example, here's how we register a button:

1
2
button_name = label.strip()
self.widget_registry[button_name] = button  # Register button by name

4. Support for Widget Alignment

Labels in the script now support text alignment, allowing the user to specify whether the label’s text should be left, center, or right aligned. This provides more control over the layout of the generated UI.

1
label.setAlignment(Qt.AlignmentFlag.AlignCenter)  # Center alignment

The script syntax supports this enhancement:

1
Label Label2(Result:Center)

This creates a label with the text "Result" centered in its container.

5. Dynamic Command Parsing

The custom scripting language includes commands like Button_Clicked, which are dynamically parsed and executed. Here’s how it works:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def _parse_command(self, line):
    if line.startswith("Button_Clicked"):
        parts = line.split(':')
        button_name = parts[1]
        action = parts[2]
        target = parts[3]
        self.commands.append({
            "button_name": button_name,
            "action": action,
            "target": target
        })

This method parses the button click command and stores it, allowing for delayed execution when the button is actually clicked. The action is bound using partial, capturing both the action and the target widget to apply the changes when the button is clicked.

1
button.clicked.connect(partial(self._execute_action, action, target))

6. Command Execution Based on Script Instructions

When a button is clicked, the associated command is executed. For example, the action to print the content of a text input is defined and applied dynamically:

1
2
3
4
5
6
7
8
9
def _execute_action(self, action, target):
    if action.startswith("print"):
        widget = self.widget_registry.get(target_widget)
        if isinstance(widget, QLineEdit):
            print(widget.text())
            if target:
                widget1 = self.widget_registry.get(target)
                if isinstance(widget1, QLabel):
                    widget1.setText(widget.text())

In this example, the Submit button prints the content of a QLineEdit and updates a QLabel with the same text. This demonstrates the ability to build interactive forms and real-time updates within the custom UI.

Sample Script

Here’s an example script demonstrating all of the new features:

1
2
3
4
5
6
7
8
Label Label1(Enter text)
TextInput Text1(Type text here)
Buttons Submit
Label Label2(Result:Center)

Command:
    Button_Clicked:Submit:print Text1:Label2
End Command

And here is the rendered screen:



This script creates a simple form with a text input and a submit button. Upon clicking the "Submit" button, the text from Text1 is printed and displayed in Label2, which is centered.

Conclusion

By expanding the custom script interpreter, we've added significant flexibility and functionality. Users can now define actions, align labels, and work with a cleaner interface thanks to automatic widget clearing. The widget registry ensures full control over the dynamically created UI elements, making this tool powerful for building custom user interfaces without needing to write traditional PyQt code.

These enhancements pave the way for building more complex applications with minimal effort, offering a higher level of abstraction for UI development. Whether you’re designing forms, dashboards, or simple tools, this script-based approach makes development faster and more intuitive.

And here is the complete program listing:

  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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
import sys
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QSplitter, QTextEdit, QPushButton, 
    QVBoxLayout, QHBoxLayout, QWidget, QScrollArea, QLabel, QLineEdit, 
    QSpacerItem, QSizePolicy
)
from PyQt6.QtCore import Qt
from functools import partial
import pdb
class ScriptInterpreter:
    
    def __init__(self, script, preview_container):
        self.script = script
        self.preview_container = preview_container
        self.widget_registry = {} # Store references to widgets by name
        self.commands = []  # Store commands to be executed

    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
        in_command_block = False
        
        # Parse the script line by line
        lines = self.script.splitlines()
        for line in lines:
            
            line = line.strip()
           

            
            
            if line.startswith("Buttons") and end_horizontal_found == True:
                self._create_button(line)
            elif line.startswith("Horizontal:"):
                # Start a new horizontal layout
                horizontal_layout = QHBoxLayout()
                horizontal_layout.setSpacing(10)
                self._add_hbox_layout_to_container(horizontal_layout)
                end_horizontal_found = False
            
                
                
            
                
            elif line.startswith("Buttons") and end_horizontal_found == False:
                self._create_button(line)
                
            elif line.startswith("Label") and end_horizontal_found == False:
                self._create_label(line, horizontal_layout)
               
            elif line.startswith("TextInput") and end_horizontal_found == False:
                self._create_text_input(line, horizontal_layout)
                
                
            
                
            elif line.startswith("Label") and end_horizontal_found == True:
                    self._create_label(line)
                    
            elif line.startswith("TextInput") and end_horizontal_found == True:
                    self._create_text_input(line)
                    
            elif line.startswith("End Horizontal"):
                end_horizontal_found = True
            # Check for Command blocks
            elif line.startswith("Command:"):
                #pdb.set_trace() 
                in_command_block = True
                continue
            
            elif line.startswith("End Command"):
                in_command_block = False
                self._apply_commands()
                continue
            elif in_command_block == True :
                self._parse_command(line)
                
                continue
                

        

        # Add spacer to push everything to the top
        spacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
        layout.addItem(spacer)
    def _parse_command(self, line):
        """
        Parse commands from the Command block.
        """
        #pdb.set_trace()
        if line.startswith("Button_Clicked"):
            parts = line.split(':')
            
            button_name = parts[1]
            
            action = parts[2]
            target = parts[3]
            self.commands.append({
                "button_name": button_name,
                "action": action,
                "target": target
            })
            

    def _apply_commands(self):
        #pdb.set_trace()
        """
        Apply the parsed commands to the corresponding widgets.
        """
        for command in self.commands:
            button_name = command["button_name"]
            action = command["action"]
            target = command["target"]
            
            action = action.strip()
            button_name= button_name.strip()
            target = target.strip()
            
        
            # Retrieve the button from the registry
            button = self.widget_registry.get(button_name.strip())
            
        
            if button is not None:
                # Use lambda to capture the action argument properly
                #button.clicked.connect(lambda _, a=action: self._execute_action(a))
                button.clicked.connect(partial(self._execute_action, action, target))

    def _execute_action(self, action, target):
        #pdb.set_trace()
        """
        Execute the action when the button is clicked.
        """
        if action.startswith("print"):
            # Extract the target of the print command
            
            _, target_widget = action.split(maxsplit=1)
            
            widget = self.widget_registry.get(target_widget)
            
            if isinstance(widget, QLineEdit):
                # Print the content of the text input
                print(widget.text())
                if target:
                    widget1 = self.widget_registry.get(target)
                    if isinstance(widget1, QLabel):
                        widget1.setText(widget.text())

    def _create_button(self, line, layout=None):
        _, label = line.split(maxsplit=1)
        button = QPushButton(label)
        button.setFixedHeight(40)  # Set fixed height
        button_name = label.strip()
        self.widget_registry[button_name] = button  # Register button by name
        if layout != None:
            layout.addWidget(button)
        else:
            self.preview_container.layout().addWidget(button)

    def _create_label(self, line, layout=None):
        # Extract label information and alignment
        label_parts = line.split("(")
        label_name = label_parts[0].split()[1].strip()  # Label name (like Label1)
    
        if len(label_parts) > 1:
            text_and_align = label_parts[1].rstrip(")").split(":")
            label_text = text_and_align[0].strip()  # Label text (like "Result")
            alignment = text_and_align[1].strip().lower() if len(text_and_align) > 1 else 'left'
        else:
            label_text = ""
            alignment = 'left'
    
        label = QLabel(label_text)
        label.setFixedHeight(40)  # Set fixed height
        
        # Register the text input by its name
        self.widget_registry[label_name] = label

        # Set alignment based on the provided value (left, center, right)
        if alignment == 'center':
            label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        elif alignment == 'right':
            label.setAlignment(Qt.AlignmentFlag.AlignRight)
        else:
            label.setAlignment(Qt.AlignmentFlag.AlignLeft)
            
        if layout != None:
            layout.addWidget(label)
        else:
            self.preview_container.layout().addWidget(label)

    def _create_text_input(self, line, layout=None):
        
        # Extract the name and placeholder from the line
        name_placeholder = line[len("TextInput "):]  # Remove "TextInput " part
        name, placeholder = name_placeholder.split("(", 1)
        name = name.strip()
        placeholder = placeholder.rstrip(")")  # Remove closing parenthesis
        
        text_input = QLineEdit()
        text_input.setPlaceholderText(placeholder)
        text_input.setFixedHeight(40)  # Set fixed height

        # Register the text input by its name
        self.widget_registry[name] = text_input

        
        if layout != None:
            layout.addWidget(text_input)
        else:
            self.preview_container.layout().addWidget(text_input)

    def _add_hbox_layout_to_container(self, layout):
        # Create a container widget for the HBoxLayout
        hbox_widget = QWidget()
        hbox_widget.setLayout(layout)
        #hbox_widget.setFixedHeight(50)  # Set a fixed height for the HBoxLayout widget
        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 get_all_widgets(self, scroll_area):
        
        # Get the container widget inside the QScrollArea
        container = scroll_area.widget()

        # Check if the container has a layout
        if container.layout() is not None:
            # Get all widgets from the container
            widgets = []
            layout = container.layout()
            for i in range(layout.count()):
                widget = layout.itemAt(i).widget()
                if widget is not None:
                    widgets.append(widget)
            return widgets
        else:
            return []

    def run_script(self):
        # Get the layout of the container inside the scroll area
        layout = self.preview_container.layout()
    
        # Remove all widgets from the layout
        while layout.count():
            item = layout.takeAt(0)  # Get the first item from the layout
            widget = item.widget()   # Get the widget from the layout item
            if widget is not None:
                widget.setParent(None)  # Remove the widget from the layout
        
        # Get the script from the text editor
        script = self.text_edit.toPlainText()
        

        # 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())

No comments:

Post a Comment