feat: implement export/import functionality and UI enhancements for Blockly workspace
parent
09e67ccbbc
commit
c82e30b9b1
|
|
@ -234,7 +234,9 @@ amr-ros-k4/ # ROS2 Workspace root
|
||||||
│ ├── breakpoints.js # Debug breakpoint management
|
│ ├── breakpoints.js # Debug breakpoint management
|
||||||
│ ├── bridge.js # executeAction — pywebview bridge
|
│ ├── bridge.js # executeAction — pywebview bridge
|
||||||
│ ├── debug-engine.js # Run, debug, step, stop logic
|
│ ├── 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)
|
├── blocks/ # One file per block (auto-discovered)
|
||||||
│ ├── manifest.js # BLOCK_FILES array
|
│ ├── manifest.js # BLOCK_FILES array
|
||||||
│ ├── led_on.js
|
│ ├── led_on.js
|
||||||
|
|
@ -357,10 +359,13 @@ If the executor is not running, tests are **skipped** with an informative messag
|
||||||
|
|
||||||
| Component | Description |
|
| 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. |
|
| [`_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. |
|
||||||
| [`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>()`. |
|
| [`_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.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. |
|
| [`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>()`. |
|
||||||
| [`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. |
|
| [`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.
|
**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):
|
**Script loading order** (fixed — never needs changing when adding blocks):
|
||||||
1. `vendor/blockly.min.js` + other Blockly libs
|
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`
|
3. `blockly/blocks/manifest.js` + `blockly/loader.js`
|
||||||
4. `blockly/workspace-init.js`
|
4. `blockly/workspace-init.js`
|
||||||
5. Inline script: `loadAllBlocks().then(() => initWorkspace())`
|
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.getBlocks()` | Get all registered blocks |
|
||||||
| `BlockRegistry.getToolboxJSON()` | Build Blockly toolbox JSON from registered blocks + built-in categories |
|
| `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
|
#### [`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).
|
**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
|
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"
|
### pywebview shows "GTK cannot be loaded"
|
||||||
|
|
||||||
**Symptom:** Warning about `ModuleNotFoundError: No module named 'gi'` followed by "Using Qt 5.15".
|
**Symptom:** Warning about `ModuleNotFoundError: No module named 'gi'` followed by "Using Qt 5.15".
|
||||||
|
|
|
||||||
28
readme.md
28
readme.md
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
> **Project**: Blockly ROS2 Robot Controller (Kiwi Wheel AMR)
|
> **Project**: Blockly ROS2 Robot Controller (Kiwi Wheel AMR)
|
||||||
> **ROS2 Distro**: Jazzy
|
> **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).
|
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.
|
bab pada dokumen merepresentasikan alur rencana pengembangan.
|
||||||
Potential Enhancements : Feasibility Study
|
Potential Enhancements : Feasibility Study
|
||||||
Planned Feature : Backlog
|
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
|
# Potential Enhancements
|
||||||
this list is short by priority
|
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
|
- **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
|
- **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)
|
- **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_executor` | Action Server — command handler registry | ✅ Done |
|
||||||
| `blockly_interfaces` | Custom ROS2 action definitions | ✅ Done |
|
| `blockly_interfaces` | Custom ROS2 action definitions | ✅ Done |
|
||||||
| `kiwi_controller` | Adaptive control for Kiwi Wheel drive | 📋 Planned |
|
| `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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
to JavaScript, bridging Blockly's runtime with the ROS2 Executor Node.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import tkinter as _tk
|
||||||
|
from tkinter import filedialog as _filedialog
|
||||||
|
|
||||||
import webview
|
import webview
|
||||||
import rclpy
|
import rclpy
|
||||||
from rclpy.action import ActionClient
|
from rclpy.action import ActionClient
|
||||||
|
|
@ -23,6 +27,43 @@ from blockly_interfaces.action import BlocklyAction
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
def _wait_for_future(future, timeout_sec: float = 30.0):
|
||||||
"""
|
"""
|
||||||
Wait for an rclpy Future to complete without calling spin.
|
Wait for an rclpy Future to complete without calling spin.
|
||||||
|
|
@ -141,6 +182,50 @@ class BlocklyAPI:
|
||||||
"message": f"Error: {str(e)}",
|
"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():
|
def main():
|
||||||
"""Application entry point."""
|
"""Application entry point."""
|
||||||
|
|
|
||||||
|
|
@ -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)';
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,17 @@ function initWorkspace() {
|
||||||
// Build toolbox from registry
|
// Build toolbox from registry
|
||||||
const toolbox = BlockRegistry.getToolboxJSON();
|
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
|
// Inject Blockly workspace
|
||||||
const ws = Blockly.inject('blockly-div', {
|
const ws = Blockly.inject('blockly-div', {
|
||||||
toolbox: toolbox,
|
toolbox: toolbox,
|
||||||
|
|
@ -38,7 +49,7 @@ function initWorkspace() {
|
||||||
scaleSpeed: 1.2,
|
scaleSpeed: 1.2,
|
||||||
},
|
},
|
||||||
trashcan: true,
|
trashcan: true,
|
||||||
theme: Blockly.Themes.Classic,
|
theme: DarkTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up breakpoint click listener
|
// 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
|
// Resize handler
|
||||||
function onResize() {
|
function onResize() {
|
||||||
const area = document.getElementById('blockly-area');
|
const area = document.getElementById('blockly-area');
|
||||||
|
|
|
||||||
|
|
@ -85,12 +85,77 @@
|
||||||
height: 16px;
|
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 {
|
#blockly-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
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 {
|
#blockly-div {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -129,15 +194,27 @@
|
||||||
<button id="btn-step-into" onclick="stepInto()" disabled>Step Into</button>
|
<button id="btn-step-into" onclick="stepInto()" disabled>Step Into</button>
|
||||||
<div class="separator"></div>
|
<div class="separator"></div>
|
||||||
<button id="btn-stop" onclick="stopExecution()" disabled>Stop</button>
|
<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">
|
<div id="debug-toggle">
|
||||||
<input type="checkbox" id="chk-debug" onchange="onDebugToggle(this.checked)">
|
<input type="checkbox" id="chk-debug" onchange="onDebugToggle(this.checked)">
|
||||||
<label for="chk-debug">Debug Mode</label>
|
<label for="chk-debug">Debug Mode</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Blockly Workspace -->
|
<!-- Blockly Workspace + Code Tab -->
|
||||||
<div id="blockly-area">
|
<div id="workspace-area">
|
||||||
<div id="blockly-div"></div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Console Output -->
|
<!-- Console Output -->
|
||||||
|
|
@ -169,6 +246,8 @@
|
||||||
<script src="blockly/core/bridge.js"></script>
|
<script src="blockly/core/bridge.js"></script>
|
||||||
<script src="blockly/core/debug-engine.js"></script>
|
<script src="blockly/core/debug-engine.js"></script>
|
||||||
<script src="blockly/core/ui-controls.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 -->
|
<!-- Block manifest and loader -->
|
||||||
<script src="blockly/blocks/manifest.js"></script>
|
<script src="blockly/blocks/manifest.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue