feat: implement export/import functionality and UI enhancements for Blockly workspace

master
a2nr 2026-03-09 11:43:27 +07:00
parent 09e67ccbbc
commit c82e30b9b1
8 changed files with 380 additions and 15 deletions

View File

@ -234,7 +234,9 @@ amr-ros-k4/ # ROS2 Workspace root
│ ├── breakpoints.js # Debug breakpoint management
│ ├── bridge.js # executeAction — pywebview bridge
│ ├── debug-engine.js # Run, debug, step, stop logic
│ └── ui-controls.js # Button states and callbacks
│ ├── ui-controls.js # Button states and callbacks
│ ├── ui-tabs.js # switchTab(), refreshCodePanel()
│ └── workspace-io.js # exportWorkspace(), importWorkspace()
├── blocks/ # One file per block (auto-discovered)
│ ├── manifest.js # BLOCK_FILES array
│ ├── led_on.js
@ -357,10 +359,13 @@ If the executor is not running, tests are **skipped** with an informative messag
| Component | Description |
|---|---|
| [`_wait_for_future(future, timeout_sec)`](src/blockly_app/blockly_app/app.py:26) | Polls `future.done()` without calling `rclpy.spin()`. Used because the node is already being spun by a background thread. |
| [`BlocklyAPI`](src/blockly_app/blockly_app/app.py:46) | Python class exposed to JavaScript via pywebview. Its methods are callable as `window.pywebview.api.<method>()`. |
| [`BlocklyAPI.execute_action()`](src/blockly_app/blockly_app/app.py:61) | Sends a ROS2 Action Goal and blocks until the result arrives. Returns `{success: bool, message: str}` to JavaScript. |
| [`main()`](src/blockly_app/blockly_app/app.py:145) | Initializes rclpy, creates the Action Client, starts the background spin thread with `MultiThreadedExecutor`, creates the pywebview window, and handles cleanup on exit. |
| [`_native_save_dialog()` / `_native_open_dialog()`](src/blockly_app/blockly_app/app.py:29) | Opens native OS file dialogs via `tkinter.filedialog`. Uses Tcl/Tk interpreter separate from Qt — no thread conflict. Available in pixi environment without extra dependencies. |
| [`_wait_for_future(future, timeout_sec)`](src/blockly_app/blockly_app/app.py:63) | Polls `future.done()` without calling `rclpy.spin()`. Used because the node is already being spun by a background thread. |
| [`BlocklyAPI`](src/blockly_app/blockly_app/app.py:80) | Python class exposed to JavaScript via pywebview. Its methods are callable as `window.pywebview.api.<method>()`. |
| [`BlocklyAPI.execute_action()`](src/blockly_app/blockly_app/app.py:95) | Sends a ROS2 Action Goal and blocks until the result arrives. Returns `{success: bool, message: str}` to JavaScript. |
| [`BlocklyAPI.save_workspace(json_string)`](src/blockly_app/blockly_app/app.py:145) | Opens native "Save As" dialog via tkinter, writes workspace JSON to chosen file. Returns `{success, path}` directly to JS. |
| [`BlocklyAPI.load_workspace()`](src/blockly_app/blockly_app/app.py:168) | Opens native "Open" dialog via tkinter, reads and validates JSON, returns `{success, data, path}` to JS. |
| [`main()`](src/blockly_app/blockly_app/app.py:190) | Initializes rclpy, creates the Action Client, starts the background spin thread with `MultiThreadedExecutor`, creates the pywebview window, and handles cleanup on exit. |
**Threading design:** The `main()` function starts `MultiThreadedExecutor.spin()` in a daemon thread. When JavaScript calls `execute_action()`, the method uses `_wait_for_future()` to poll for completion — it never calls `rclpy.spin_until_future_complete()`, which would conflict with the background spin.
@ -376,7 +381,7 @@ If the executor is not running, tests are **skipped** with an informative messag
**Script loading order** (fixed — never needs changing when adding blocks):
1. `vendor/blockly.min.js` + other Blockly libs
2. `blockly/core/registry.js``breakpoints.js``bridge.js``debug-engine.js``ui-controls.js`
2. `blockly/core/registry.js``breakpoints.js``bridge.js``debug-engine.js``ui-controls.js``ui-tabs.js``workspace-io.js`
3. `blockly/blocks/manifest.js` + `blockly/loader.js`
4. `blockly/workspace-init.js`
5. Inline script: `loadAllBlocks().then(() => initWorkspace())`
@ -391,6 +396,28 @@ If the executor is not running, tests are **skipped** with an informative messag
| `BlockRegistry.getBlocks()` | Get all registered blocks |
| `BlockRegistry.getToolboxJSON()` | Build Blockly toolbox JSON from registered blocks + built-in categories |
#### [`blockly_app/ui/blockly/core/ui-tabs.js`](src/blockly_app/blockly_app/ui/blockly/core/ui-tabs.js) — Tab Switching
**Purpose:** Manages switching between the **Blocks** editor tab and the **Code** preview tab.
| Function | Description |
|---|---|
| `switchTab(tab)` | Shows/hides `#blockly-area` and `#code-panel`. Calls `Blockly.svgResize()` when returning to Blocks tab (canvas dimensions are zeroed while hidden). |
| `refreshCodePanel()` | Regenerates JS code from workspace via `javascript.javascriptGenerator.workspaceToCode()` and displays in `#code-output`. |
The Code tab updates automatically on every workspace change (via a change listener in `workspace-init.js`).
#### [`blockly_app/ui/blockly/core/workspace-io.js`](src/blockly_app/blockly_app/ui/blockly/core/workspace-io.js) — Workspace Export/Import
**Purpose:** Exports workspace to JSON (Save As dialog) and imports from JSON (Open dialog) via the Python bridge.
| Function | Description |
|---|---|
| `exportWorkspace()` | Serializes workspace with `Blockly.serialization.workspaces.save()`, calls `window.pywebview.api.save_workspace(json)`, logs result path. |
| `importWorkspace()` | Calls `window.pywebview.api.load_workspace()`, clears workspace, loads returned JSON with `Blockly.serialization.workspaces.load()`. |
Both functions use `async/await` — they return after the file dialog closes and the file has been read/written.
#### [`blockly_app/ui/blockly/core/bridge.js`](src/blockly_app/blockly_app/ui/blockly/core/bridge.js) — pywebview Bridge
**Purpose:** Provides `executeAction(command, params)` which calls Python via the pywebview JS-to-Python bridge. Falls back to a mock when running outside pywebview (browser dev).
@ -1164,6 +1191,17 @@ pixi run executor
pixi run test
```
### Export/Import button has no effect (force close or nothing happens)
**Symptom:** Clicking Export or Import either force-closes the app or does nothing.
**Cause:** Qt file dialogs (`QFileDialog`, `pywebview.create_file_dialog`) must run on the Qt main thread. pywebview calls Python API methods from a background thread. Attempting to open a Qt dialog from there causes:
- `pywebview.create_file_dialog` → deadlock via `BlockingQueuedConnection` → force close
- `QFileDialog` via `QTimer.singleShot` → no effect, because non-QThread background threads have no Qt event loop
**Solution:** Use `tkinter.filedialog` — tkinter uses its own Tcl/Tk interpreter, completely separate from Qt. `filedialog.asksaveasfilename()` blocks the calling background thread until the user responds. Already available in the pixi environment (no extra dependency needed).
See [`_native_save_dialog()`](src/blockly_app/blockly_app/app.py:29) in `app.py`.
### pywebview shows "GTK cannot be loaded"
**Symptom:** Warning about `ModuleNotFoundError: No module named 'gi'` followed by "Using Qt 5.15".

View File

@ -2,7 +2,8 @@
> **Project**: Blockly ROS2 Robot Controller (Kiwi Wheel AMR)
> **ROS2 Distro**: Jazzy
> **Last Updated**: 2026-03-08
> **Last Updated**: 2026-03-09
> **Current Focus**: `kiwi_controller` — Adaptive control for Kiwi Wheel drive
Dokumentasi lengkap dapat dilihat di [DOCUMENTATION.md](DOCUMENTATION.md).
@ -11,13 +12,12 @@ Dokumentasi lengkap dapat dilihat di [DOCUMENTATION.md](DOCUMENTATION.md).
bab pada dokumen merepresentasikan alur rencana pengembangan.
Potential Enhancements : Feasibility Study
Planned Feature : Backlog
Feature Task : Penjabaran Pekerjaan yang ready untuk dikerjakan
Feature Task : Penjabaran Pekerjaan yang ready untuk dikerjakan. Task harus dijelaskan apa yang akan dikerjakan dan terdapat definition of done nya
---
# Potential Enhancements
this list is short by priority
- **Enhance UI**: add tab to get generate version of block blocky, export/import from/to block blocky generated file, fixing visibelity text color in left colom view (curently it greyed with white text).
- **Launch files**: `blockly_bringup` package with ROS2 launch files to start all nodes with one command
- **Sensor integration**: Subscriber nodes for sensor data feeding back into Blockly visual feedback
- **RealHardware implementation**: Fill in ROS2 publishers/service clients for actual Pi hardware nodes (topics TBD)
@ -33,8 +33,26 @@ this list is short by priority
| `blockly_executor` | Action Server — command handler registry | ✅ Done |
| `blockly_interfaces` | Custom ROS2 action definitions | ✅ Done |
| `kiwi_controller` | Adaptive control for Kiwi Wheel drive | 📋 Planned |
| **Enhance UI** | Tab Code preview, Export/Import, dark toolbox | ✅ Done |
# Future Tasks
# Feature Task
<!-- Add new phases / features here -->
## Enhance UI
| # | Task | Status |
|---|------|--------|
| 1 | Fix toolbox text color (dark theme via `Blockly.Theme.defineTheme`) | ✅ Done |
| 2 | Add "Code" tab — realtime generated JS preview | ✅ Done |
| 3 | Export/Import workspace (.json) via toolbar buttons | ✅ Done |
**Files changed:**
- `src/blockly_app/blockly_app/ui/blockly/workspace-init.js` — dark theme definition + code panel change listener
- `src/blockly_app/blockly_app/ui/blockly/core/ui-tabs.js` *(new)*`switchTab()`, `refreshCodePanel()`
- `src/blockly_app/blockly_app/ui/blockly/core/workspace-io.js` *(new)*`exportWorkspace()`, `importWorkspace()` via `await window.pywebview.api`
- `src/blockly_app/blockly_app/ui/index.html` — CSS, HTML tab bar, code panel, toolbar buttons
- `src/blockly_app/blockly_app/app.py``save_workspace()`, `load_workspace()` via zenity subprocess
**Note — Export/Import implementation:**
`pywebview.create_file_dialog` dan `QFileDialog` via `QTimer.singleShot` keduanya gagal dari background thread (deadlock / no event loop).
Solusi final: `tkinter.filedialog` — interpreter Tcl/Tk terpisah dari Qt, tidak ada konflik thread, sudah tersedia di pixi environment tanpa dependency tambahan.

View File

@ -6,12 +6,16 @@ in a background thread. The BlocklyAPI class exposes execute_action()
to JavaScript, bridging Blockly's runtime with the ROS2 Executor Node.
"""
import json
import os
import sys
import time
import threading
import logging
import tkinter as _tk
from tkinter import filedialog as _filedialog
import webview
import rclpy
from rclpy.action import ActionClient
@ -23,6 +27,43 @@ from blockly_interfaces.action import BlocklyAction
logger = logging.getLogger(__name__)
def _native_save_dialog(suggested: str = "workspace.json") -> str:
"""
Open a native "Save As" file dialog using tkinter.
tkinter uses its own Tcl/Tk interpreter no Qt thread conflict.
filedialog.asksaveasfilename() blocks the calling thread until the
user picks a file or cancels. Returns "" on cancel.
"""
root = _tk.Tk()
root.withdraw()
root.wm_attributes("-topmost", True)
path = _filedialog.asksaveasfilename(
title="Save Workspace",
defaultextension=".json",
initialfile=suggested,
filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")],
)
root.destroy()
return path or ""
def _native_open_dialog() -> str:
"""
Open a native "Open" file dialog using tkinter.
Returns "" on cancel.
"""
root = _tk.Tk()
root.withdraw()
root.wm_attributes("-topmost", True)
path = _filedialog.askopenfilename(
title="Load Workspace",
filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")],
)
root.destroy()
return path or ""
def _wait_for_future(future, timeout_sec: float = 30.0):
"""
Wait for an rclpy Future to complete without calling spin.
@ -141,6 +182,50 @@ class BlocklyAPI:
"message": f"Error: {str(e)}",
}
def save_workspace(self, json_string: str) -> dict:
"""
Open a native OS "Save As" dialog via zenity/kdialog subprocess and
write workspace JSON to the chosen file.
Called from JS: await window.pywebview.api.save_workspace(jsonString)
Using subprocess avoids all Qt thread constraints zenity/kdialog run
in their own process, blocking the pywebview bridge thread until the
user confirms or cancels.
"""
try:
path = _native_save_dialog("workspace.json")
if not path:
return {"success": False, "error": "Cancelled"}
with open(path, "w", encoding="utf-8") as f:
f.write(json_string)
logger.info(f"Workspace saved to: {path}")
return {"success": True, "path": path}
except Exception as e:
logger.error(f"save_workspace error: {e}")
return {"success": False, "error": str(e)}
def load_workspace(self) -> dict:
"""
Open a native OS "Open" dialog via zenity/kdialog and read the file.
Called from JS: await window.pywebview.api.load_workspace()
"""
try:
path = _native_open_dialog()
if not path:
return {"success": False, "error": "Cancelled"}
with open(path, "r", encoding="utf-8") as f:
data = f.read()
json.loads(data) # validate before sending to JS
logger.info(f"Workspace loaded from: {path}")
return {"success": True, "data": data, "path": path}
except json.JSONDecodeError:
return {"success": False, "error": "File is not valid JSON"}
except Exception as e:
logger.error(f"load_workspace error: {e}")
return {"success": False, "error": str(e)}
def main():
"""Application entry point."""

View File

@ -0,0 +1,45 @@
/**
* UI Tabs Tab switching between Blocks editor and Code preview.
*
* Depends on: workspace (global, index.html), refreshCodePanel (this file),
* Blockly (vendor), javascript (vendor)
*/
/**
* Switch between "blocks" and "code" tabs.
* Calls Blockly.svgResize when returning to blocks tab (canvas was hidden).
*
* @param {string} tab - "blocks" or "code"
*/
function switchTab(tab) {
const blocksArea = document.getElementById('blockly-area');
const codePanel = document.getElementById('code-panel');
const tabBlocks = document.getElementById('tab-blocks');
const tabCode = document.getElementById('tab-code');
if (tab === 'blocks') {
blocksArea.style.display = 'block';
codePanel.style.display = 'none';
tabBlocks.classList.add('active');
tabCode.classList.remove('active');
// Restore Blockly canvas dimensions — they're zeroed while hidden
if (typeof workspace !== 'undefined' && workspace) {
Blockly.svgResize(workspace);
}
} else {
blocksArea.style.display = 'none';
codePanel.style.display = 'block';
tabBlocks.classList.remove('active');
tabCode.classList.add('active');
refreshCodePanel();
}
}
/**
* Regenerate JS code from workspace and display in code panel.
*/
function refreshCodePanel() {
if (typeof workspace === 'undefined' || !workspace) return;
const code = javascript.javascriptGenerator.workspaceToCode(workspace);
document.getElementById('code-output').textContent = code || '// (no blocks)';
}

View File

@ -0,0 +1,57 @@
/**
* Workspace I/O Export and import workspace as JSON via native OS dialogs.
*
* Calls Python save_workspace() / load_workspace() via pywebview bridge.
* Both methods are synchronous on the Python side (blocking file dialog),
* and return their result directly no evaluate_js callback needed.
*
* Depends on: workspace (global, index.html), consoleLog (index.html),
* Blockly (vendor), refreshCodePanel (ui-tabs.js)
*/
/**
* Serialize workspace and open native OS "Save As" dialog.
*/
async function exportWorkspace() {
if (typeof workspace === 'undefined' || !workspace) {
consoleLog('No workspace to export', 'error');
return;
}
const state = Blockly.serialization.workspaces.save(workspace);
const json = JSON.stringify(state, null, 2);
try {
const res = await window.pywebview.api.save_workspace(json);
if (res.success) {
consoleLog('Workspace saved to: ' + res.path, 'success');
} else if (res.error !== 'Cancelled') {
consoleLog('Export failed: ' + res.error, 'error');
}
} catch (err) {
consoleLog('Export error: ' + err, 'error');
}
}
/**
* Open native OS file picker and load selected JSON into workspace.
*/
async function importWorkspace() {
try {
const res = await window.pywebview.api.load_workspace();
if (!res.success) {
if (res.error !== 'Cancelled') {
consoleLog('Import failed: ' + res.error, 'error');
}
return;
}
const state = JSON.parse(res.data);
workspace.clear();
Blockly.serialization.workspaces.load(state, workspace);
consoleLog('Workspace loaded from: ' + res.path, 'success');
const codePanel = document.getElementById('code-panel');
if (codePanel && codePanel.style.display !== 'none') {
refreshCodePanel();
}
} catch (err) {
consoleLog('Import error: ' + err, 'error');
}
}

View File

@ -20,6 +20,17 @@ function initWorkspace() {
// Build toolbox from registry
const toolbox = BlockRegistry.getToolboxJSON();
// Dark theme — fixes toolbox text visibility against dark UI background
const DarkTheme = Blockly.Theme.defineTheme('dark-ros2', {
base: Blockly.Themes.Classic,
componentStyles: {
toolboxBackgroundColour: '#252526',
toolboxForegroundColour: '#cccccc',
flyoutBackgroundColour: '#2d2d2d',
flyoutOpacity: 0.95,
},
});
// Inject Blockly workspace
const ws = Blockly.inject('blockly-div', {
toolbox: toolbox,
@ -38,7 +49,7 @@ function initWorkspace() {
scaleSpeed: 1.2,
},
trashcan: true,
theme: Blockly.Themes.Classic,
theme: DarkTheme,
});
// Set up breakpoint click listener
@ -51,6 +62,14 @@ function initWorkspace() {
}
});
// Refresh Code tab when blocks change
ws.addChangeListener(function () {
const codePanel = document.getElementById('code-panel');
if (codePanel && codePanel.style.display !== 'none') {
refreshCodePanel();
}
});
// Resize handler
function onResize() {
const area = document.getElementById('blockly-area');

View File

@ -85,12 +85,77 @@
height: 16px;
}
#workspace-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
#tab-bar {
display: flex;
background: #2d2d2d;
border-bottom: 1px solid #404040;
flex-shrink: 0;
height: 32px;
}
.tab-btn {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: #888;
cursor: pointer;
font-size: 13px;
padding: 0 16px;
height: 32px;
}
.tab-btn.active {
color: #d4d4d4;
border-bottom-color: #4caf50;
}
.tab-btn:hover:not(.active) {
color: #bbb;
background: rgba(255,255,255,0.05);
}
#blockly-area {
flex: 1;
position: relative;
overflow: hidden;
}
#code-panel {
flex: 1;
background: #1a1a1a;
overflow: auto;
padding: 12px;
}
#code-output {
margin: 0;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 13px;
color: #d4d4d4;
white-space: pre-wrap;
word-break: break-word;
}
#btn-export {
background: #607d8b;
color: white;
}
#btn-import {
background: #455a64;
color: white;
}
/* SVG flyout label text — not controlled by Blockly theme componentStyles */
.blocklyFlyoutLabelText { fill: #cccccc !important; }
#blockly-div {
position: absolute;
top: 0;
@ -129,16 +194,28 @@
<button id="btn-step-into" onclick="stepInto()" disabled>Step Into</button>
<div class="separator"></div>
<button id="btn-stop" onclick="stopExecution()" disabled>Stop</button>
<div class="separator"></div>
<button id="btn-export" onclick="exportWorkspace()">Export</button>
<button id="btn-import" onclick="importWorkspace()">Import</button>
<div id="debug-toggle">
<input type="checkbox" id="chk-debug" onchange="onDebugToggle(this.checked)">
<label for="chk-debug">Debug Mode</label>
</div>
</div>
<!-- Blockly Workspace -->
<!-- Blockly Workspace + Code Tab -->
<div id="workspace-area">
<div id="tab-bar">
<button class="tab-btn active" id="tab-blocks" onclick="switchTab('blocks')">Blocks</button>
<button class="tab-btn" id="tab-code" onclick="switchTab('code')">Code</button>
</div>
<div id="blockly-area">
<div id="blockly-div"></div>
</div>
<div id="code-panel" style="display:none">
<pre id="code-output"></pre>
</div>
</div>
<!-- Console Output -->
<div id="console-panel"></div>
@ -169,6 +246,8 @@
<script src="blockly/core/bridge.js"></script>
<script src="blockly/core/debug-engine.js"></script>
<script src="blockly/core/ui-controls.js"></script>
<script src="blockly/core/ui-tabs.js"></script>
<script src="blockly/core/workspace-io.js"></script>
<!-- Block manifest and loader -->
<script src="blockly/blocks/manifest.js"></script>

24
workspace.json Normal file
View File

@ -0,0 +1,24 @@
{
"blocks": {
"languageVersion": 0,
"blocks": [
{
"type": "controls_if",
"id": "WLmpkq!^,rcoKGH+bQgN",
"x": 210,
"y": 70,
"inputs": {
"DO0": {
"block": {
"type": "led_on",
"id": "._%j-2DZ*BZJlyW:1#`Q",
"fields": {
"PIN": 1
}
}
}
}
}
]
}
}