From c82e30b9b11635b1ff527eeea408f40e1cb0b707 Mon Sep 17 00:00:00 2001 From: a2nr Date: Mon, 9 Mar 2026 11:43:27 +0700 Subject: [PATCH] feat: implement export/import functionality and UI enhancements for Blockly workspace --- DOCUMENTATION.md | 50 +++++++++-- readme.md | 28 ++++-- src/blockly_app/blockly_app/app.py | 85 +++++++++++++++++++ .../blockly_app/ui/blockly/core/ui-tabs.js | 45 ++++++++++ .../ui/blockly/core/workspace-io.js | 57 +++++++++++++ .../blockly_app/ui/blockly/workspace-init.js | 21 ++++- src/blockly_app/blockly_app/ui/index.html | 85 ++++++++++++++++++- workspace.json | 24 ++++++ 8 files changed, 380 insertions(+), 15 deletions(-) create mode 100644 src/blockly_app/blockly_app/ui/blockly/core/ui-tabs.js create mode 100644 src/blockly_app/blockly_app/ui/blockly/core/workspace-io.js create mode 100644 workspace.json diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6c6d190..8287684 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -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.()`. | -| [`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.()`. | +| [`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". diff --git a/readme.md b/readme.md index be071a8..693ea43 100644 --- a/readme.md +++ b/readme.md @@ -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 - \ No newline at end of file +## 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. diff --git a/src/blockly_app/blockly_app/app.py b/src/blockly_app/blockly_app/app.py index 7070fac..6e39ae6 100644 --- a/src/blockly_app/blockly_app/app.py +++ b/src/blockly_app/blockly_app/app.py @@ -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.""" diff --git a/src/blockly_app/blockly_app/ui/blockly/core/ui-tabs.js b/src/blockly_app/blockly_app/ui/blockly/core/ui-tabs.js new file mode 100644 index 0000000..57ab8f6 --- /dev/null +++ b/src/blockly_app/blockly_app/ui/blockly/core/ui-tabs.js @@ -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)'; +} diff --git a/src/blockly_app/blockly_app/ui/blockly/core/workspace-io.js b/src/blockly_app/blockly_app/ui/blockly/core/workspace-io.js new file mode 100644 index 0000000..c6a12b0 --- /dev/null +++ b/src/blockly_app/blockly_app/ui/blockly/core/workspace-io.js @@ -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'); + } +} diff --git a/src/blockly_app/blockly_app/ui/blockly/workspace-init.js b/src/blockly_app/blockly_app/ui/blockly/workspace-init.js index e83f258..953e6ce 100644 --- a/src/blockly_app/blockly_app/ui/blockly/workspace-init.js +++ b/src/blockly_app/blockly_app/ui/blockly/workspace-init.js @@ -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'); diff --git a/src/blockly_app/blockly_app/ui/index.html b/src/blockly_app/blockly_app/ui/index.html index 3b3d946..8a67d93 100644 --- a/src/blockly_app/blockly_app/ui/index.html +++ b/src/blockly_app/blockly_app/ui/index.html @@ -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,15 +194,27 @@
+
+ +
- -
-
+ +
+
+ + +
+
+
+
+
@@ -169,6 +246,8 @@ + + diff --git a/workspace.json b/workspace.json new file mode 100644 index 0000000..505100e --- /dev/null +++ b/workspace.json @@ -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 + } + } + } + } + } + ] + } +} \ No newline at end of file